在实际应用中所有的服务端程序都需要在多线程之间进行某种同步。大多数同步已经有框架完成了,比如我们的web服务器,DB客户端和消息框架。Java和Scala提供了大量的组件用来实现稳定的多线程程序。包括对象池,并发集合,高级锁,执行上下文等。
为了更好的理解这些组件,我们深入了解一下最常用的同步原语——对象所。这个是用synchronized 关键字来实现的,在Java中它是非常流行的多线程原语。这也是其他更复杂模式的基础,比如线程池和连接池,并发集合等。
Synchronized 关键字主要用在以下两个场景:
作为方法的修饰,此方法在同一个时间只能被一个线程执行。
把一个代码块声明为临界区,任何时间只有一个线程能访问。
锁指令
同步代码块有两个专门的字节码指令,MonitorEnter 和 MoniterExit。这个不同于其它锁机制,比如java.util.concurrent包是有Java代码和本地调用来是实现的。
这些作用在对象上的指令是有开发人员在同步块中显示说明的。对于同步方法,锁被加到了“this”对象上。对于静态方法,锁被加到了类对象上。
同步方法有时候会引起坏的结果。其实一个例子是在不同的同步方法间会引起隐式依赖,因为它们共享同一把锁 。更坏的场景是在基类里(可能是一个第三方库)申明了一个同步方法,在子类里又增加了新的同步方法。这造成了不同层次之间的隐式同步依赖,会降低吞吐率甚至是死循环。这时应该使用私有的锁对象来防止偶然的共享,或者不使用锁。
编译器和同步
有两个字节码的指令用来实现同步原语。这并不常见,因为大多数字节码指令是互相独立的,通常通过把值放在线程的操作数堆栈上来互相“通信”。要加锁的对象也是从操作数堆栈装载的,通过引用变量或者方法返回对象把他们放在操作堆栈上。
如果只有其中一个指令被调用了,而另外一个没被调用,会发生什么?Java编译器不会产生只调用MonitorExit而没有调用MonitorEnter的代码。即使Java编译产生了这样的代码 ,在JVM看来这个代码也是非法的。这会让MonitorExit指令抛出一个IllegalMonitorStateException 异常。
一个更危险的例子是MonitorEnter加了锁,但却没有被对应的MonitorExit释放。这种情况下线程一直拥有锁,从而导致其他想获取这把锁的线程一直被阻塞。
为了防止发生一直被阻塞,Java编译器会以以下方式产生代码:一旦进入同步块或者方法,一定能执行到MonitorExit。在临界区抛出异常可以导致这个问题。
编译器使用的机制非常简单,当异常发生时如果没有经过MonitorExit,就增加catch语句来释放锁。
另一个问题是在enter和exit之间的锁对象存储在何处。注意多个线程可以同时执行同步块,使用不同的锁对象。如果锁对象是方法调用的结果,那么JVM极有可能会再次执行它,因为它可能会改变对象状态,或者不会返回同一个对象。如果是同一个对象,那么在monitor执行前这个变量和域已经被改变。
监视变量。为了计算,编译器为方法增加了一个隐式的变量,用来存储锁状态。这个是一个聪明的解决方案,因为它只增加了很小的开销就维护了锁对象,而不是使用并发栈把锁对象隐射到线程(这个结构需要同步)。我是在编译Takipi栈分析算法时发现这个新变量的。
注意这所有的工作都是有Java的编译器完成的。JVM可以非常完美处理只调用MonitorEnter来进入临界区而不用退出(或者相反),或者为方法使用不同的对象。
JVMLevel的锁
让我们更深入的看一下锁在JVM里是怎么实现的。为此我们将会查看HotSpot SE7的实现,因为这个实现每个VM都可能不一样。 因为加锁会影响代码的吞吐率,JVM加入了很大的优化来提高加锁解锁的效率。
其中一个强大机制是线程锁偏向。锁特性是每个Java对象都具有的,很想系统的hashcode或者定义类的引用。不管类的类型是什么,这个都成立(甚至你可以使用一个原始数组作为锁)。
这些类型的数据都存在对象的头部(也被称为对象的标记)。这些数据中的一部分用来描述对象锁的状态。这包括描述对象的锁状态(加锁/没加锁)的比特标志位,一个指向现在拥有锁的线程。
为了节省对象头部的空间,Java的线程对象会被分配在VM栈的低位,这可以减少地址长度从而节约对象头部的比特数(64位的只需要54位,32位的只需要23位)。
64位的比特位分配情况:
锁算法
当JVM尝试去获取一个对象的锁时,会采用从乐观到悲观的步骤来获取。
当线程成为对象锁的拥有者时,这就算加锁成功了。线程是否把指向自己的引用存入对象的头部决定着加锁是否成功。
获取锁的步骤。第一步是使用CAS操作来尝试获取锁。这个操作非常高效,因为常常有与之对应的CPU指令(比如 cmpxchg)。 CAS操作和OS线程停止程序作为对象的同步原语。
如果锁是空闲或者说这个所可以被这个线程优先获取,那么线程就立即获取了这个锁。如果CAS失败了,那么JVM先会自旋一轮,然后线程会睡眠,直到下次CAS。如果这些最初的尝试失败了,线程会把自己放入阻塞状态,并进入竞争锁的列表,开始一系列的自旋。
释放锁。通过执行MonitorExit指令来退出临界区,锁的拥有者会尝试着检查是否可以唤醒正在等待这把锁的线程 。这个过程被称为选择一个继承者。这可以增加活跃度,阻止出现当锁释放后仍有线程在等待这把锁。
调试服务端多线程问题有点难度,因为它们非常依赖调试时机和OS的特性。这也是让我们实现TAkipi的原因之一。
延伸阅读