java并发与多线程(四):线程同步
1 同步是什么
资源共享的两个原因是资源紧缺和共建需求。线程共享CPU是从资源紧缺的维度来考虑的,而多线程共享同一变量,通常是从共建需求的维度来考虑的。在多个线程对同一变量进行写操作时,如果操作没有原子性,就可能产生脏数据。所谓原子性是指不可分割的一系列操作指令,在执行完毕前不会被任何其他操作中断,要么全部执行,要么全部不执行。如果每个线程的修改都是原子操作,就不存在线程同步问题。有些看似非常简单的操作其实不具备原子性,典型的就是i++操作,它需要分为三步,即ILOAD->IINC->ISTORE。另一方面,更加复杂的CAS(Compare And)操作却具有原子性。
计算机的形成同步,就是线程之间按某种机制协调先后次序执行,当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作。实现线程同步的方式有很多,比如同步方法、锁、阻塞队列等。
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慢得多,对应借书的例子,应该得去国外图书馆了。
接着再谈指令优化。计算机并不会根据代码顺序按部就班地执行相关指令,再回到借书的例子,加入你刚好要去还书,然后再借一本,你的室友恰好也让你帮他归还Easy Coding这本书,然后再借一本《码出高效》。这个过程有两件事,你的事和他的事。先办你的事,再办他的事,是一种单线程的死板行为。此时你会潜意识地进行“指令优化”:把你要还的数和Easy Coding先一起归还,再一起借你们要借的书,这就相当于合并数据进行存取的操作过程。CPU在处理信息时也会进行指令优化,分析哪些取数据动作可以合并进行,哪些存数据动作可以合并进行。CPU拜访一趟遥远的内存,一定会到处看看,是否可以存取合并,以提高执行效率。指令重排示例代码如下:
image.pnghappen before是时钟顺序的先后,并不能保证线程交互的可见性。在第2处和第3处都是写操作,不会进行指令重排,但是前三行是不互斥的,并且第1处的操作如果放在z=3赋值操作之后,明显是效率最大化的处理方式。所以指令重排的最大可能是把第1处和第2处串联一次执行。Happen before并不能保证线程交互的可见性。那么什么是可见性呢?可见性是指某线程修改共享变量的指令对其他线程来说都是可见的,它反映的是指令执行的实时透明度。
每个线程都有独占的内存区域,如操作栈、本地变量表等。线程本地内存保存了引用变量在堆内存中的副本,线程对变量的所有操作都在本地内存区域中进行,执行结束后再同步到堆内存中去。这里必然有一个时间差,在这个时间差内,改线程对副本的操作,对于其他线程是不可见的。
Volatile的英文本义是“挥发、不稳定的”,延伸意义为敏感的。当使用volatile修饰变量时,意味着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。由此可见,在使用单例设计模式时,即使是双检锁也不一定会拿到最新的数据。
如下示例代码在高并发场景中会存在问题:
image.png
image.png
使用者在调用getTransactionService()时,有可能会得到初始化未完成的对象,究其原因,与java虚拟机的编译优化有关。对java虚拟机而言,初始化TransactionService实例和将对象地址写到service字段并非原子操作,且这两个阶段的执行顺序是未定义的。假设某个线程执行new TransactionService()时,构造方法还未被调用,编译器仅仅为该对象分配了内存空间并设为默认值,此时若另一个线程调用getTransactionService()方法,由于service!= null,但是此时service对象还没有被赋予真正有效的值,从而无法获取到正确的service单例对象。这就是著名的双重检查锁定(Doubble-checked Locking)问题,对象引用在没有同步的情况下进行读操作,导致用户可能会获取未构造完成的对象。对于此问题,一种较为简单的解决方案是用volatile关键字修饰目标属性(适用于JDK5及以上版本),这样service就限制了编译器对它的相关读写操作,对它的读写操作进行指令重排,确定对象实例化之后才返回引用。锁也可以确保变量的可见性,但是实现方式和volatile略有不同。线程在得到锁时读入副本,释放时写回内存,锁的操作尤其要符合happen before原则。
Volatile解决的是多线程共享变量的可见性问题,类似于synchronized,但不具备synchronized的互斥性。所以对volatile变量的操作并非都具有原子性,这是一个容易犯错误的地方。一个线程对共享变量进行10000次i++操作,另一个线程进行10000次i--操作,如下示例代码:
image.png
image.png
多次执行后,发现结果基本都不为0.如果在count++和count--两处都进行加锁操作,才会得到预期是0的结果。这里对count的读取、加1操作的字节码如下:
image.png
需要4步才能完成加1操作。在该过程中,其他线程有足够的时间覆盖变量的值,如果想让示例代码最后的结果为零,需要对count++和count--加锁:
image.png
能实现count++原子操作的其他类有AtomicLong和LongAddr。JDK8推荐使用LongAddr类,它比AtomicLong性能更好,有效地减少了乐观锁的重试次数。
因此,“volatile是轻量级的同步方式”这种说法是错误的。它只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,一定会产生线程安全问题。如果是一写多读的并发场景,使用volatile修饰变量则非常合适。Volatile一写多读最典型的应用时CopyOnWriteArrayList。它在修改数据时会把整个集合的数据全部复制出来,对写操作加锁,修改完成后,再用setArray()把array指向新的集合。使用volatile可以使读线程尽快地感知array的修改,不进行指令重排,操作后即可对其他线程可见。源码如下:
image.png
在实际业务中,如何清晰地判断一写多读的场景显得尤为重要。如果不确定共享变量是否会被多个线程并发写,保险的做法是使用同步代码块来实现线程同步。另外,因为所有的操作都需要同步给内存变量,所以volatile一定会使线程的执行速度变慢,故要审慎定义和使用volatile属性。
3信号量同步
信号量同步是指在不同的线程之间,通过传递同步信号量来协调线程执行的先后次序。这里重点分析基于时间维度和信号维度的两个类:CountDownLatch、Semaphore。
某国际化基础语言管理平台收到一个多语言翻译请求后,根据目标语种拆分成多个子线程,对翻译引擎发起翻译请求。翻译完成后,同步返回给调用方,结果由于countDown()抛出异常,导致发生故障,警示代码如下:
image.png
image.png
代码中第1处抛出异常,且该异常没有被主线程try-catch到,最终该线程没有执行countDown()方法。程序执行的时间较长,该问题难以定位,因为异常被吞得一干二净。扩展说明一下,子线程异常可以通过线程方法setUncaughtExceptionHandler()捕获。
CountDownLatch是基于执行时间的同步类。在实际编码中,可能需要处理基于空闲信号的同步情况。比如海关安检的场景,任何国家公民在出国时,都要走海关的查验通道。假设某机场的海关通道共有三个窗口,一批需要出关的人排成长队,每个人都是一个线程。当3个窗口中的任意一个出现空闲时,工作人员指示队列中第一个人出队到该空闲窗口接收检查。对于上述场景,JDK中提供了一个Semaphore的信号同步类,只有在调用Semaphore对象的acquire()成功后,才可以往下执行,完成后执行release()释放持有的信号量,下一个线程就可以马上获取这个空闲信号量进入执行。基于Semaphore的示例代码如下:
image.png
image.png
如果某个人身份可疑,需要确认更多的信息,这不会影响到其他窗口的安检速度。只要其他线程能够拿到空闲信号,都可以马上执行。如果Semaphore的窗口信号量等于2,就是最典型的互斥锁。
还有其他同步方式,如CyclicBarrier是基于同步到达某个点的信号量触发机制。CyclicBarrier从命名上即可知道它是一个可以循环使用(Cyclic)的屏障式(Barrier)多线程协作方式。采用这种方式进行刚才的安检服务,就是3个人同时进去,只有3个人都完成安检,才会放下一批进来。这是一种非常低效的安检方式。但在某种场景下就是非常正确的方式,假设在机场排队打车时,现场工作人员统一指挥,每次放3辆车进来,坐满后开走,再放下一批车和人进来。通过CyclicBarrier的reset()来释放线程资源。
最后温馨提示,无论从性能还是安全性上考虑,我们进来使用并发包中提供的信号同步类,避免使用对象的wait()和notify()方式来进行同步。