Java最佳实践经验第78条:同步访问共享的可变数据
本来计划一天分享一条,但是博主最近临近面试,时间上实在不太充裕,博主会尽量保证最高的效率撰写。
#2020年度征文
摘要
众所周知,我们在设计程序的时候,设计一个良好可正常运行的并发程序的难度,是远大于设计一个单线程程序的,因为有更多的可能会产生错误,有些失败想要复现也是很困难的。但只要注意一些多线程的编写规范,很多问题是可以有效避免的。
1、同步的二重含义
1.1、互斥访问
关键字synchronized
可以保证在同一时刻,只有一个线程可以执行一个方法,或者某一个代码块。这是一个互斥 的概念,但很多程序员把同步的概念仅仅理解为一种互斥的方式,这是不完整的。
1.2、状态一致和可见性
如果没有同步,一个线程的变化就不能被其他线程看到,同步不仅可以阻止对象处于不一致的状态之中,它还可以保证进入同步方法或者同步代码块的每个线程,都能看到由同一个锁保护的之前的所有的修改效果。
2、对于各线程共享的可变数据,一定要能够同步访问
Java语言规范保证读或者写一个变量是原子的(atomic),除非这个变量的类型为long
或者double
。换句话说,读取一个非long
或者double
类型的变量,可以保证返回值是某个线程保存在该变量中的,即使多个线程在没有同步的情况下并发地修改这个变量也是如此。
为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的
虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的,这是由Java语言规范中的内存模型
决定的,它规定了一个线程所做的变化何时及如何变成对其他线程可见。
举一个例子:阻止一个线程妨碍另一个线程的任务
。Java类库中虽然提供了Thread stop
方法,但是在很久以前就不提倡使用该方法了,因为它本质上是不安全的
——使用它会导致数据被破坏,所以千万不要使用·Thread stiop·方法去阻止一个线程。我们可以采用使用另一个线程poll(轮询)
的方式来实现。
public class Demo1 {
private static boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested){
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
stopRequested
是一个布尔类型的属性,初始值为false
,通过主线程将其设置为true
,来终止backgroundThread
线程的循环。从逻辑上乍一看好像没有问题,你是否期待这个程序会在运行1秒后停止呢?
运行结果如下所示:永远不会停止,因为我的后台线程在一直循环
问题出在哪呢?
由于没有同步,就不能保证后台线程何时“看到”主线程对stopRequested = true
的改变,JVM会将
while(!stopRequested){
i++;
}
这一代码转变为:
if(!stopRequested){
while(true){
i++;
}
}
导致后台线程永远看不到主线程对stopRequested
属性发出的值改变命令,这种优化称作为提升(hoisting)
,正是JVM的工作,导致的结果是一个活性失败
问题:这个程序事实上并没有得到提升。修正这个问题是方式有两种。
2.1 同步访问stopRequested
属性
public class Demo1_2 {
private static boolean stopRequested;
private static synchronized void requestStop(){
stopRequested = true;
}
private static synchronized boolean stopRequested(){
return stopRequested;
}
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested()){
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
可以发现在1s后程序停止了运行。这里要注意的是读和写方法都需要被同步,否则无法100%保证同步能起作用
。
2.1 使用volatile
声明stopRequested
属性
如果使用volatile
声明stopRequested
属性,就可以省略synchronized
同步锁。volatile
修饰符不执行互斥访问,但可以保证另一个线程能立刻看到属性值的改变。
public class Demo1_3 {
private static volatile boolean stopRequested;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested){
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}
3、谨慎地使用volatile
修饰符
来看下面这个例子:
public class Demo1_4 {
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
}
这个方法的目的是要确保每次调用都返回不同的值(只要不超过int
的上限2^32
次调用)。这个方法的状态只包含了一个可原子访问的属性nextSerialNumber
,看起来似乎不需要任何同步来保护它。然而,如果没有同步来保护方法generateSerialNumber
,这个方法仍然无法正确地工作。
其中的问题出在操作符(++)不是原子的,它包含了两项操作:先读取nextSerialNumber
的原值,再把+1的结果写回nextSerialNumber
。如果在这两步操作之间有第二个线程读取了这个属性,那么就会读到错误的结果。这就是安全性失败
。
对于安全性失败
也有两种解决办法。
3.1 为方法上synchronized
修饰符
只要为方法加上同步,就可以严格保证++过程中只有一个线程访问,确保每个调用都会看到调用之前的效果。这样volatile
也就自然地可以删去了。为了使得这个方法更加可靠,我们用long
来代替int
。
public class Demo1_5 {
private static long nextSerialNumber = 0;
public static synchronized long generateSerialNumber() {
return nextSerialNumber++;
}
}
3.2 使用AtomicLong
类
它是java.util.concurrent.atomic
的组成部分,这个包为单个变量上进行免锁定、线程安全的编程提供了基本类型。它同时提供了同步的通信效果和原子性。这正是让generateSerialNumber
顺利执行的完美方案。
public class Demo1_6 {
private static final AtomicLong nextSerialNumber = new AtomicLong();
public static long generateSerialNumber() {
return nextSerialNumber.getAndIncrement();
}
}
4、安全发布
避免本篇前文中所涉及到的一系列问题的最佳办法是:不共享可变的数据。要么共享不可变数据,要么压根不共享数据。换句话说,就是将可变的数据限制在单个线程内,不对外开放
。如果采用了这一策略,对它建立文档就很重要。
总结前面的案例,我们可以发现导致线程不安全的主因主要出在数据写入不可见
,所以如果我们让一个线程在短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,它只同步共享对象引用的动作。这种对象被称作高效不可变
。将这种对象引用从一个线程传递到其他线程被称作安全发布
。安全发布对象引用有很多种方法:可以将它保存在静态属性中,作为类初始化的一部分;可以将它保存在volatile属性、final属性或者通过正常锁定访问的属性中;或者可以将它放到并发的集合中。
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原语值变得更加困难。实现安全发布对象的一种技术就是将对象引用定义为 volatile
类型,并将生成对象和读取对象分离到不同的线程中,下面展示了一个示例,其中后台线程在启动阶段从数据库加载一些数据。其他线程在能够利用这些数据时,在使用之前将检查这些数据是否已经准备完毕,并无权对其进行修改。
public class Demo1_7 {
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
BackgroundFloobleLoader.initInBackground();
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1); //静止一秒让后台进程运行完
SomeOtherClass.doWork();
}
}
class BackgroundFloobleLoader {
private static volatile Flooble theFlooble;
public static Flooble getFlooble(){
return theFlooble;
}
public static void initInBackground() {
theFlooble = new Flooble();
}
}
class SomeOtherClass {
private static void doSomething(Object obj){
System.out.println("我成功做了接收到了后台线程发布的数据: " + obj);
}
public static void doWork() {
if (BackgroundFloobleLoader.getFlooble() != null) {
doSomething(BackgroundFloobleLoader.getFlooble());
} else {
System.out.println("后台线程还没准备好要发布的数据!");
}
}
}
class Flooble{
}
看下输出的结果:
因为我们这里没有私有了BackgroundFloobleLoader
类中的theFlooble
属性,只提供了getter方法
,也就相当于把这个属性变成了不可变的对象,防止了其他线程的写入,确保线程安全。
总结
总而言之,当多个线程共享可变数据的时候,每个读或写数据的线程都必须执行同步
。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败
和安全性失败
。这样的失败是最难调试的,它们可能是间接性的,且与时间相关,且程序的行为在不同的虚拟机上也会有根本不同。
如果只需要实现线程之间的交互通信,而不需要实现互斥,那么volatile
修饰符就是一种可以接受的同步形式。
下一篇: C++之标准模板库STL