JUC并发编程 共享模型之不可变
如果一个对象是不可以改变的,没有人能够修改这些变量,那么即使他是共享的,那也是线程安全的。
所以本章我将要讲解:
- 不可变类的使用
- 不可变类的设计
- 无状态类的设计
1. 日期转换的问题
1.1 问题提出
下面的代码的运行:
@Slf4j
public class CH7 {
public static void main(String[] args) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}).start();
}
}
}
执行结果:
20:50:05.423 [Thread-8] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
20:50:05.423 [Thread-5] DEBUG com.example.javatest.CH7 - Wed Apr 21 00:00:00 CST 1
20:50:05.424 [Thread-3] ERROR com.example.javatest.CH7 - {}
java.lang.NumberFormatException: For input string: ".404E2.404E2"
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at com.example.javatest.CH7.lambda$main$0(CH7.java:20)
at java.lang.Thread.run(Thread.java:748)
20:50:05.423 [Thread-9] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
20:50:05.423 [Thread-7] DEBUG com.example.javatest.CH7 - Tue Apr 21 00:00:00 CST 1970
20:50:05.423 [Thread-4] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
20:50:05.423 [Thread-6] DEBUG com.example.javatest.CH7 - Tue Apr 21 00:00:00 CST 1970
20:50:05.423 [Thread-2] DEBUG com.example.javatest.CH7 - Wed Apr 21 00:00:00 CST 1954
20:50:05.424 [Thread-0] ERROR com.example.javatest.CH7 - {}
java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at com.example.javatest.CH7.lambda$main$0(CH7.java:20)
at java.lang.Thread.run(Thread.java:748)
20:50:05.424 [Thread-1] ERROR com.example.javatest.CH7 - {}
java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at com.example.javatest.CH7.lambda$main$0(CH7.java:20)
at java.lang.Thread.run(Thread.java:748)
由于 SimpleDateFormat
不是线程安全的,有很大几率出现 java.lang.NumberFormatException
或者出现不正确的日期解析结果。
对于正确的输出,线程成功地将日期字符串解析为日期对象,并记录了正确的日期值。
对于错误的输出,可能是因为多个线程同时访问了非线程安全的 SimpleDateFormat
实例,导致解析错误。在这些情况下,日期字符串没有正确地被解析,或者可能包含了不合法的字符,导致了 NumberFormatException
异常。
为了解决这些问题,你可以采取以下措施:
- 使用线程安全的日期格式化类,如
java.time.DateTimeFormatter
。 - 使用线程池管理线程,以减少线程创建和销毁的开销。
- 确保日志输出是线程安全的,或者使用适当的同步机制来保护非线程安全的资源。
- 在异常处理中,更好地记录异常信息,以便更容易排查问题。
1.2 思路 - 同步锁
这样虽能解决问题,但带来的是性能上的损失,并不算很好:
@Slf4j
public class CH7 {
public static void main(String[] args) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
synchronized (sdf){
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}
}).start();
}
}
}
21:00:26.165 [Thread-0] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-9] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-8] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-7] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-6] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-5] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-4] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-1] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-3] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
21:00:26.168 [Thread-2] DEBUG com.example.javatest.CH7 - Sat Apr 21 00:00:00 CST 1951
1.3 思路 - 不可变
如果一个对象在不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改啊!
这样的对象在Java 中有很多,例如在 Java 8 后,提供了一个新的日期格式化类:DateTimeFormatter
@Slf4j
public class CH7 {
public static void main(String[] args) {
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
log.debug("{}", dtf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}).start();
}
}
}
21:02:57.589 [Thread-2] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-8] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-9] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-3] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-5] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-7] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-0] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-6] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-4] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
21:02:57.589 [Thread-1] DEBUG com.example.javatest.CH7 - {},ISO resolved to 1951-04-21
不可变对象,实际是另一种避免竞争的方式。
2. 不可变设计
另一个大家更为熟悉的 String
类也是不可变的,以它为例,说明一下不可变设计的要素:
大家声明的字符串其实被赋值给了value数组。
2.1 final 的使用
发现该类、类中所有属性都是 final
的
- 属性用
final
修饰保证了该属性是只读的,不能修改 - 类用
final
修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
其实你还会发现,里面的hash属性并没有使用final来修饰,那是不是外部使用String就可以更改hash值呢了,会不会导致不安全呢?
其实并不会,你执行查看String的源码,你会发现,其实hash并没有对应的set方法,也就是外部并不能改变hash值,所以也间接是安全的了。
2.2 保护性拷贝
但有同学会说,使用字符串时,也有一些跟修改相关的方法啊,比如 substring
等,那不也是改变了String里面的值了吗(value)?那么下面就看一看这些方法是如何实现的,就以 substring
为例:
发现其内部是调用 String
的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value
做出了修改:
结果发现也没有,构造新字符串对象时,会生成新的 char[] value
,对内容进行复制 。
这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】
简单的来说不可变的设计要素包括:
- 私有状态:不可变类的状态(成员变量)应该被声明为
private
,以确保外部代码不能直接修改它们。- **将成员变量声明为
final
**:这将确保它们在初始化之后不会被修改。- 不要提供可变方法:不可变类不应该提供修改状态的方法。如果需要提供某种修改后的版本,可以返回一个新的不可变实例。
- 防御性复制:如果不可变类包含可变对象,如集合,应该在构造函数中进行防御性复制,以防止外部代码修改内部数据。(也是保护性拷贝)
- 尽量减少成员变量:不可变类应该尽量减少成员变量的数量,以简化类的设计。
2.3 享元模式
对于前面的不可变设计,虽然是线程安全的,但是如果创建的副本对象一多,对于JVM也是也是很占用内存的。
为了解决上面这种情况方式,Java在处理这方面使用了享元模式。
2.3.1 简介
享元设计模式(Flyweight Design Pattern)是一种结构型设计模式,旨在最小化对象的内存占用或计算开销,通过共享相似对象的部分状态来减少重复对象的数量。这种模式适用于大量相似对象的情况,可以有效地减少内存消耗和提高性能。
享元模式包含以下主要组成部分:
享元工厂(Flyweight Factory):这是一个用于创建和管理共享享元对象的工厂类。它通常维护一个享元对象池,以确保相同的享元对象只创建一次,并提供一个接口让客户端获取或检索享元对象。
享元接口(Flyweight):这是享元对象的接口,声明了共享对象的外部接口。享元对象通常包含两种状态:内部状态和外部状态。内部状态是可共享的,而外部状态是不可共享的。
具体享元对象(Concrete Flyweight):这是享元对象的具体实现,实现了享元接口。具体享元对象包含内部状态,而外部状态可以通过参数传递给具体享元对象的方法。
客户端(Client):客户端是使用享元模式的应用程序的一部分。它负责维护对享元工厂的引用,并通过工厂获取或检索共享享元对象。
享元模式的工作流程如下:
客户端通过享元工厂请求一个享元对象,传递外部状态作为参数。
享元工厂首先检查对象池中是否已经存在一个具有相同内部状态的享元对象。如果存在,工厂将返回现有对象的引用。
如果不存在相同内部状态的对象,工厂将创建一个新的具体享元对象,并将其添加到对象池中。
最后,客户端使用返回的享元对象,并在需要时设置外部状态。
享元模式的优点包括:
减少内存消耗:通过共享相似对象的内部状态,可以大大减少内存消耗,尤其在有大量对象需要创建的情况下。
提高性能:减少对象的数量可以提高程序的性能,因为创建和销毁对象通常会消耗时间。
分离内部状态和外部状态:享元模式可以明确分离对象的内部状态和外部状态,使代码更容易维护和理解。
然而,享元模式也有一些限制和注意事项:
对象池的管理可能会增加复杂性,需要谨慎处理对象的共享和释放。
外部状态可能需要复杂的管理和同步,以确保多个享元对象之间的正确状态。
不适用于所有情况,只有当有大量相似对象需要共享内部状态时才有意义。
2.3.2 体现
包装类:
在JDK中 Boolean
,Byte
,Short
,Integer
,Long
,Character
等包装类提供了 valueOf
方法,例如 Long 的valueOf 会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象,大于这个范围,才会新建 Long 对象:
注意:
- Byte, Short, Long 缓存的范围都是 -128~127
- Character 缓存的范围是 0~127
- Integer的默认范围是 -128~127
- 最小值不能变
- 但最大值可以通过调整虚拟机参数
-Djava.lang.Integer.IntegerCache.high
来改变- Boolean 缓存了 TRUE 和 FALSE
String 串池、BigDecimal、BigInteger 这几个也是线程安全的,但是有的人看了前面几章节可能会问,之前有个案例:
public class JavaTestApplication {
public static void main(String[] args) {
DecimalAccount.demo(new DecimalAccountSafeCas(new BigDecimal(10000)));
}
}
class DecimalAccountSafeCas implements DecimalAccount {
AtomicReference<BigDecimal> ref; //改用原子引用,也能够完成
public DecimalAccountSafeCas(BigDecimal balance) {
ref = new AtomicReference<>(balance);
}
@Override
public BigDecimal getBalance() {
return ref.get();
}
@Override
public void withdraw(BigDecimal amount) {
while (true) {
BigDecimal prev = ref.get();
BigDecimal next = prev.subtract(amount);
if (ref.compareAndSet(prev, next)) {
break;
}
}
}
}
interface DecimalAccount {
// 获取余额
BigDecimal getBalance();
// 取款
void withdraw(BigDecimal amount);
/**
* 方法内会启动 1000 个线程,每个线程做 -10 元 的操作
* 如果初始余额为 10000 那么正确的结果应当是 0
*/
static void demo(DecimalAccount account) {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
ts.add(new Thread(() -> {
account.withdraw(BigDecimal.TEN);
}));
}
ts.forEach(Thread::start);
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(account.getBalance());
}
}
这章提到,明明BigDecimal
是线程安全的,那为什么还要使用AtomicReference
来保证他的线程安全呢?
因为BigDecimal
是线程安全的这并不假,他的方法都是线程安全的,但是如果多个线程安全的方法组合在一起,那么就不能够保证他的线程安全,正如对应上面的代码:
@Override
public void withdraw(BigDecimal amount) {
while (true) {
BigDecimal prev = ref.get();
BigDecimal next = prev.subtract(amount);
if (ref.compareAndSet(prev, next)) {
break;
}
}
}
这里显然get
、subtract
方法都是线程安全的,但是他们组合在一起使用 ,有可能导致线程不安全,所以要使用AtomicReference
来保证线程安全的方法的组合也是线程安全的。
3. 无状态
在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的
因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】