1.同步是什么
资源共享的两个原因是资源紧缺和共建需求。线程共享 CPU 是从资源紧缺的维度来考虑的,而多线程共享同一变量,通常是从共建需求的维度来考虑的。在多个线程对同一变量进行写操作时,如果操作没有原子性,就可能产生脏数据。所谓原子性是指不可分割的一系列操作指令,在执行完毕前不会被任何其他操作中断,要么全部执行,要么全部不执行。如果每个线程的修改都是原子操作,就不存在线程同步问题有些看似非常简单的操作其实不具备原子性,典型的就是i++操作,它需要分为三步即ILOAD一INC一ISTORE。另一方面,更加复杂的CAS (Compare And Swap)操作却具有原子性。
线程同步现象在实际生活随处可见。比如乘客在火车站排队打车,每个人都是个线程,管理员每次放10个人进来,为了保证安全,等全部离开后,再放下一批人进来。如果没有协调机制,场面一定是混乱不堪的,人们会一窝蜂地上去抢车,存在严重的安全隐患。计算机的线程同步,就是线程之间按某种机制协调先后次序执行,当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作。实现线程同步的方式有很多,比如同步方法、锁、阻塞队列等。
2.volatile
先从 happen before了解线程操作的可见性。把happen before定义为方法hb(a,b),表示 a happen before b。如果 hb(a,b) 且 hb(b,c),能够推导出 hb(a,c)。类似于x>y 且y>z,可以推导出x>z。这不就是一种放之四海而皆准的规律吗? 但其实很多场景并不符合这种规律,比如在 2018 年俄罗斯世界杯上,韩国队战胜德国队,德国队战胜瑞典队,并不能推导出韩国队战胜了瑞典队。
线程执行或线程切换都是纳秒级的,执行速度如此之快,直觉上会认为线程本地缓存的必要性特别弱。做个类比,我们人类以年为计而宇宙以亿年为计,宇宙老人看待人类的心态不正如我们看待 CPU世界的心态吗? 时间成本的巨大差异只要存在缓存策略自然就会产生。再比如,去学校图书馆仅需要10分钟,借一本书,无须缓存。但如果去市图书馆,往返需要 5个小时,一般为了减少路程开销而会考虑多借几本。CPU 访问内存远远比访问高速缓存L1和L2慢得多,对应借书的例子,应该得去国外图书馆了。
接着再谈指令优化。CPU在处理信息时会进行指令优化,分析哪些取数据动作可以合并进行,哪些存数据动作可以合并进行。CPU拜访一趟遥远的内存,一定会到处看看,是否可以存取合并,以提高执行效率。指令重排示例代码如下:
@Override
public void run() {
// (第1处)
int x = 17
int y = 2;
int z = 3;
//(第2处)
x = x + 1;
//(第3处)
int sum = x + y + z;
}
happen before是时钟顺序的先后,并不能保证线程交互的可见性。在第2处和曾3处都是写操作,不会进行指令重排,但是前三行是不互斥的,并且第1处的操作如果放在 z=3 赋值操作之后,明显是效率最大化的处理方式。所以指令重排的最大可能是把第1处和第2处串联依次执行。happen before 并不能保证线程交互的可见性。那么什么是可见性呢?可见性是指某线程修改共享变量的指令对其他线程来说都是可见的,它反映的是指令执行的实时透明度。
每个线程都有独尚的内存区域,如操作栈、本地变量表等。线程本地内存保存了引用变量在堆内存中的副本,线程对变量的所有操作都在本地内存区域中进行,执行结束后再同步到堆内存中去。这里必然有一个时间差,在这个时间差内,该线程对副本的操作,对于其他线程都是不可见的。
volatile的英文本义是“挥发、不稳定的”,延伸意义为敏感的。当使用volatile修饰变量时,意味着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。由此可知,在使用单例设计模式时,即使用双检锁也不一定会拿到最新的数据。
如下示例代码在高并发场景中会存在问题
public class LazyInitDemo {
private static TransactionService service = null;
public static TransactionService getTransactionService() {
if (service == null) {
synchronized (LazyInitDemo.class) {
if (service == null) {
service = new TransactionService();
}
}
}
return service;
}
}
使用者在调用getTransactionService()时,有可能会得到初始化未完成的对象。究其原因,与Java虚拟机的编译优化有关。对Java编译器而言,初始化TransactionService 实例和将对象地址写到 service字段并非原子操作,且这两个阶段的执行顺序是未定义的。假设某个线程执行new TransactionService()时,构造方法还未被调用,编译器仅仅为该对象分配了内存空间并设为默认值,此时若另一个线程调用getTransactionService()方法,由于 service !=null,但是此时service对象还没有被赋予真正有效的值,从而无法取到正确的 service 单例对象。这就是著名的双重检查锁定(Double-checked Locking)问题,对象引用在没有同步的情况下进行读操作,导致用户可能会获取未构造完成的对象。对于此问题,一种较为简单的解决方案是用volatile 关键字修饰目标属性(适用于JDK5及以上版本),这样 service就限制了编译器对它的相关读写操作,对它的读写操作进行指令重排,确定对象实例化之后才返回引用。
锁也可以确保变量的可见性,但是实现方式和 volatile 略有不同。线程在得到锁时读入副本,释放时写回内存,锁的操作尤其要符合 happen before 原则。
volatile 解决的是多线程共享变量的可见性问题,类似于 synchronized,但不具备synchronized的互斥性。所以对 volatile 变量的操作并非都具有原子性,这是一个容易犯错误的地方。一个线程对共享变量进行 10000次++ 操作,另一个线程进行 10000次 i-- 操作,如下示例代码:
public class VolatileNotAtomic {
private static volatile long count = 0L;
private static final int NUMBER = 10000;
public static void main(String[] args) {
count = 0L;
Thread subThread = new SubtractThread();
subThread.start();
for (int i = 0; i < NUMBER; i++) {
count++;
}
//等待减法结束
while (subThread.isAlive()) { }
System.out.println("count 最后的值为:" + count);
}
private static class SubtractThread extends Thread {
@Override
public void run() {
for (int i = 0; i < NUMBER; i++) {
count--;
}
}
}
}
多次执行后,发现结果基本都不为0。如果在count++和count-- 两处都进行加锁操作,才会得到预期是0的结果。这里对 count 的读取、加1操作的字节码如下
// 1.读取 count 并压入操作栈顶
GETSTATIC icu/climber/coding/other/VolatileNotAtomic.count: I
//2.常量1压入操作栈顶
ICONST 1
//3.取出最顶部两个元素进行相加
IADD
// 4.将刚才得到的和赋值给 count
PUTSTATIC icu/climber/coding/other/VolatileNotAtomic.count: I
需要4步才能完成加1操作。在该过程中,其他线程有足够的时间覆盖变量的值如果想让示例代码最后的结果为零,需要对count++和count-- 加锁:
for (int i = 0; i < MAX_VALUE; i++) {
synchronized (VolatileNotAtomic.class){
//在count-- 代码处也同样进行加锁处理
count++;
}
}
能实现 count++ 原子操作的其他类有 AtomicLong和LongAdder。JDK8 推荐使用LongAdder 类,它比 AtomicLong 性能更好,有效地减少了乐观锁的重试次数。
因此,“volatile 是轻量级的同步方式”这种说法是错误的。它只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,一定会产生线程安全问题。如果是写多读的并发场景,使用volatile 修饰变量则非常合适。volatile一写多读最典型应用是 CopyOnWriteArayList。它在修改数据时会把整个集合的数据全部复制出来,对写操作加锁,修改完成后,再用 setArray() 把 array 指向新的集合。使用volatile可以使读线程尽快地感知 array 的修改,不进行指令重排,操作后即对其他线程可见。源码如下:
public class CopyOnWriteArrayList<E>{
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
/**
* Sets the array.
*/
final void setArray(Object[] a) {
array = a;
}
}
在实际业务中,如何清晰地判断一写多读的场景显得尤为重要。如果不确定共享变量是否会被多个线程并发写,保险的做法是使用同步代码块来实现线程同步。另外因为所有的操作都需要同步给内存变量,所以volatile一定会使线程的执行速度变慢故要审慎定义和使用volatile 属性。
3.信号量同步
信号量同步是指在不同的线程之间,通过传递同步信号量来协调线程执行的先后次序。这里重点分析基于时间维度和信号维度的两个类: CountDownLatch、Semaphore。
某国际化基础语言管理平台收到一个多语言翻译请求后,根据目标语种拆分成多个子线程,对翻译引擎发起翻译请求。翻译完成后,同步返回给调用方,结果由于countDown()抛出异常,导致发生故障,警示代码如下
public class CountDownLatchTest {
public static void main(String[] args) throws InterruptedException {
CountDownLatch count = new CountDownLatch(3);
Thread thread1 = new TranslateThread("1st content", count);
Thread thread2 = new TranslateThread("2nd content", count);
Thread thread3 = new TranslateThread("3rd content", count);
thread1.start();
thread2.start();
thread3.start();
count.await(10, TimeUnit.SECONDS);
System.out.println("所有线程已执行完成");
}
}
class TranslateThread extends Thread {
private String content;
private final CountDownLatch count;
public TranslateThread(String content, CountDownLatch count) {
this.content = content;
this.count = count;
}
@Override
public void run() {
//某种情况下,执行翻译出错,抛出异常(第一处)
if (Math.random() > 0.5) {
throw new RuntimeException("原文件存在非法字符");
}
System.out.println(content + "的翻译已经完成,译文为:....");
count.countDown();
}
}
输出如下:
Exception in thread "Thread-1" java.lang.RuntimeException: 原文件存在非法字符
at com.linkmiao.iot.demo.test.d202311.TranslateThread.run(CountDownLatchTest.java:41)
Exception in thread "Thread-2" java.lang.RuntimeException: 原文件存在非法字符
at com.linkmiao.iot.demo.test.d202311.TranslateThread.run(CountDownLatchTest.java:41)
1st content的翻译已经完成,译文为:....
所有线程已执行完成
代码中第1处抛出异常,且该异常没有被主线程 try-catch 到,最终该线程没有执行 countDown()方法。程序执行的时间较长,该问题难以定位,因为异常被吞得一干二净。扩展说明一下,子线程异常可以通过线程方法setUncaughtExceptionHandler()捕获。
CountDownLatch 是基于执行时间的同步类。在实际编码中,可能需要处理基于空闲信号的同步情况。比如海关安检的场景,任何国家公民在出国时,都要走海关的查验通道。假设某机场的海关通道共有了3个窗口,一批需要出关的人排成长队,每个人都是一个线程。当3个窗口中的任意一个出现空闲时,工作人员指示队列中第一个人出队到该空闲窗口接受查验。对于上述场景,JDK 中提供了一个 Semaphore 的信号同步类,只有在调用 Semaphore对象的 acquire()成功后,才可以往下执行,完成后执行release()释放持有的信号量,下一个线程就可以马上获取这个空闲信号量进入执行。基于Semaphore的示例代码如下
public class CustomCheckWindow {
public static void main(String[] args) {
//设定3个信号量,即3个服务窗口
Semaphore semaphore = new Semaphore(3);
//这个队伍5个人
for (int i = 1; i <= 5; i++) {
new SecurityCheckThread(i, semaphore).start();
}
}
private static class SecurityCheckThread extends Thread {
private int seq;
private Semaphore semaphore;
public SecurityCheckThread(int seq, Semaphore semaphore) {
this.seq = seq;
this.semaphore = semaphore;
}
@Override
public void run() {
try {
semaphore.acquire();
System.out.println("No." + seq + " 乘客正在查验中");
if (seq % 2 == 0) {
Thread.sleep(1000);
System.out.println("No." + seq + " 乘客,身份可疑,不能出国!");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
System.out.println("No." + seq + " 乘客已经完成服务");
}
}
}
}
执行结果如下
No.2 乘客正在查验中
No.1 乘客正在查验中
No.1 乘客已经完成服务
No.3 乘客正在查验中
No.3 乘客已经完成服务
No.4 乘客正在查验中
No.5 乘客正在查验中
No.5 乘客已经完成服务
No.2 乘客,身份可疑,不能出国!
No.2 乘客已经完成服务
No.4 乘客,身份可疑,不能出国!
No.4 乘客已经完成服务
如果某个人身份可疑,需要确认更多的信息,这不会影响到其他窗口的安检速度。只要其他线程能够拿到空闲信号量,都可以马上执行。如果 Semaphore 的窗口信号量等于1,就是最典型的互斥锁。
还有其他同步方式,如CyclicBarrier 是基于同步到达某个点的信号量触发机制。CyclicBarrier从命名上即可知道它是一个可以循环使用(Cyclic)的屏障式(Barrier)多线程协作方式。采用这种方式进行刚才的安检服务,就是3个人同时进去,只有3个人都完成安检,才会放下一批进来。这是一种非常低效的安检方式。但在某种场景下就是非常正确的方式,假设在机场排队打车时,现场工作人员统一指挥,每次放3辆车进来,坐满后开走,再放下一批车和人进来。通过CyclicBarrier的reset()来释放线程资源。
最后温馨提示,无论从性能还是安全性上考虑,我们尽量使用并发包中提供的信号同步类,避免使用对象的 wait()和notify()方式来进行同步。