关于Java和Scala同步机制你不知道的5个真相
本文由 ImportNew - 邬柏 翻译自 takipiblog。如需转载本文,请先参见文章末尾处的转载要求。
实际上,所有的服务器应用在多线程中都需要某种类型的同步机制。框架已经为我们做了大多数的同步的工作,这些框架包括web服务器、数据库连接客户端、消息框架等。我们可以通过Java和Scala提供的多种组件来写出可靠的多线程应用。这些组件包括,对象池、并发集合类、高级锁、执行上下文等。
为了更好的理解这个问题,接下来首先要探究最常用的同步模式:对象锁。这种机制增强了synchronized 关键字,使它成为Java编程多线程的最流行的习惯用法之一。同时,它也是许多常用复杂模式的基础,包括线程池、连接池、并发集合类等。
synchronized 关键字主要应用于以下两种情境:
- 作为一种改进方法,使被标记的某一方法在同一时间仅仅能被一个线程执行。
- 标记一个代码块为临界区块,标记后的代码块在任何时间点仅有一个线程可以执行。
锁介绍
#真相1:同步代码块是通过MonitorEnter和MonitorExit专用字节码指令来实现,这两个字节码指令也是官方参数的一部分。这种实现和其他的锁机制不同,在java.util.concurrent包中的一些方法(在hotspot上的实现)通过结合使用Java代码和使用sun.misc.Unsafe的实现本地调用。
这些指令运行在使用了sychronized上下文的对象上。对于sychronized方法,“this”变量会自动选择锁。对于静态方法,会将锁置于Class对象上。
Sychronized方法有时也会带来比较糟糕的结果,比如当不同的sychronized方法共享一个锁文件时,将会造成一些隐性依赖。如果遇到下面这种情况会变得更糟糕:比如在基类(甚至第三方类库)中声明了sychronized方法,然后又在派生类中增加了一个新的sychronized方法。这将造成整个类的层次结构对同步的隐性依赖,同时也会带来吞吐量的问题甚至死锁。为了避免出现这种问题,推荐使用私有对象作为锁对象,以此来避免意料之外的锁共享和lock溢出。
编译器与同步机制的关系
通过两个字节码指令负责完成同步工作是不同寻常的。通常字节码指令之间相互独立的,通过把值放在线程操作数堆栈上完成字节码之间的相互“通信”。被加锁的对象也预先放在操作数堆栈上,然后通过从变量、字段中取值或者方法反射的方式来从操作数堆栈上返回一个对象。
#真相2:如果仅仅调用两个字节码指令其中之一,而不调用另一个会出现什么情况?仅仅调用monitorExit却不调用MonitorEnter的代码,Java编译器不会编译通过的。即使从JVM角度来看这也是合理的。这种情况的结果就是MonitorExit指令会抛出一个IllegalMonitorStateException异常。
一种更加危险的情形是:如果一个锁通过调用MonitorEnter被获取却不被调用MonitorExit被释放会发生什么?这种情况下,得到锁的线程将会造成其他需要获取此锁的线程无限期挂起。这是毫无意义的。因为锁本身可重入,从某种意义上说,线程之间都是平等的。虽然这个线程现在快乐地执行着,当这个线程下次需要执行这段代码的时候,也需要重新获取这个锁。(译注:换句话说,到时候快乐的可是别人了,你在这干着急吧……)。
解决问题的关键就在这里。为了防止这一切的发生,Java编译器生成了相互配对的enter和exit指令。使用这种方法,一旦执行了进入synchronized的块或方法时,它必须传递一个相对应的MonitorExit给同一个对象。但是,一旦临界区域抛出异常,那么事情将变得非常糟糕。(译注:抛出异常后,程序会退出,monitorExit的字节码将不会被调用)。
public void hello() { synchronized (this) { System.out.println("Hi!, I'm alone here"); } }
让我们来分析下字节码:
aload_0 //将代码载入操作数堆栈 dup //再次载入 astore_1 //将此备份到保存在寄存器1的一个隐式变量中 monitorenter //从栈中出栈这个值来进入monitor //真正的边界区域 getstatic Java/lang/System/out LJava/io/PrintStream; ldc "Hi!, I'm alone here" invokevirtual Java/io/PrintStream/println(LJava/lang/String;)V aload_1 //获取寄存器1内的备份 monitorexit //出栈这个变量并退出monitor goto 14 // 直接跳到最后并退出 // 编译器额外添加的catch声明,如果运行中出现异常,执行以下内容 aload_1 //获取寄存器1内的备份 monitorexit //退出monitor athrow // 重新抛出异常类,将其载入操作数堆栈 return
从上面的代码分析中可以看出,编译器解决栈无法释放monitorExit的方法也非常直接:编译器会添加一个隐式的try catch声明来释放锁并抛出异常。
#真相3:另外一个问题是:在进入相应调用的enter之后、exit退出之前,被加锁的对象引用存放在什么地方。值得注意的是,多线程可能并行执行同一个同步块代码,但使用不同的锁对象。如果锁对象是一个方法反射后得到的结果,那么JVM几乎不大可能会再次执行它,因为这可能会改变对象的状态,甚至可能返回的不是同一个对象。当一个变量或者字段在monitor进入之后发生改变时,这种论断也是正确的。
Monitor变量。为了克服这个问题,编译器为方法添加了一个隐性的本地变量来保持锁对象的这个值。这是一种非常聪明的解决方案。相对于保存一个锁对象的引用,这中方法带来的损耗相对更低,因为没有使用并行堆结构来为线程获取锁对象的值(并发结构本身也可能需要加锁)。我第一次发现这个新变量是在构建Takip堆栈分析算法时,看到代码中pop了一个意料之外的变量。
我们应该意识到,所有的工作都是在Java编译器级别完成的。JVM非常乐意通过Monitor进入一个临界区段,但不退出(反之亦然);或者使用不同的对象进入相应的enter和exit的方法。
JVM锁
接下来我们进一步看看JVM级别,锁是如何实现的。在这一节中我们将查看hotspot SE 7的实现。由于锁机制对代码吞吐量有极大的影响,因此JVM做了非常多的优化使得获取锁和释放锁尽可能高效。
#真相4:JVM最强壮的机制之一就是线程的锁偏移(Locking Biasing)。锁是每个Java对象都拥有的本质属性,这就像每个对象都拥有hashcode或者他们类的引用一样。而且无论对象的类型,上述论断都是正确的(如果喜欢你甚至可以使用一个原始数组作为一个锁)。
这些数据存储在每个对象的头部(也称为类的标记)。有些放在对象头部的数据段是保留字段,专门用来存储对象的锁状态。这其中包含着表明对象锁状态的位字段(加锁/未加锁)以及当前拥有这个锁的线程:这个对象的偏移就指向这个线程。
为了给对象头部留出空间,Java线程对象位于VM堆比较低的位置,这样就可以压缩地址的大小,并且节省每个对象的头部的bit位(JVM32位的对应23bits,64位对应54bits)。
锁算法
当JVM试着去获取一个对象的锁的时候,它将经历悲喜两重天。
#真相5:一旦一个线程成功把自己编程对象锁的拥有者,那么这个线程就成功地获得了锁。这取决与线程对象能否将锁对象头部字段的引用(一个内部Java线程对象的指针)指向自己。
获取锁。获取锁的第一步是使用一个CAS(compare-and-exchange比较并改变)指令。这一步通常非常的高效,因为他一般会被翻译成一个直接的CPU指令(比如cmpxchg)。CAS操作和操作系统特定线程的暂止程序一起为对象同步提供了基础的功能。
如果锁当前可用,或者之前已经对这个线程进行了偏移,那么线程可以立刻获取对象上的锁并且继续执行线程内的程序。如果CAS失败,JVM会执行一轮自旋锁定。这时线程暂止会将线程休眠,直到再次尝试CAS。如果这些尝试失败(意味着更高级别的锁争用),线程会把自己变为挂起状态,同时进入一个线程争用队列开始一系列的自旋锁定。
释放锁。当临界区通过MonitorExit退出时,拥有锁的线程将会尝试能否唤醒等待获取锁的挂起线程。这个过程也被称作选一个“继任者”。这能激活停滞的多个线程,同时也能防止出现——锁已经被释放但其他线程却仍处在暂止状态。
调试服务器多线程问题是非常苦逼、非常困难的,因为它们常常依赖非常极端、非常特殊的时间点以及操作系统算法。这也是当初我们为Takip工作的原因。
推荐阅读
-- 扫描加关注,微信号: importnew --原文链接: takipiblog 翻译: ImportNew.com - 邬柏
译文链接: http://www.importnew.com/7799.html
[ 转载请保留原文出处、译者、译文链接和上面的微信二维码图片。]