实际上,所有服务器应用程序都需要在多个线程之间进行某种同步。 大多数同步工作是在框架级别为我们完成的,例如通过我们的Web服务器,数据库客户端或消息传递框架。 Java和Scala提供了许多组件来编写可靠的多线程应用程序。 这些包括对象池,并发集合,高级锁,执行上下文等。
为了更好地理解它们,让我们探索最同步的习惯用法-Object lock 。 这种机制为synced关键字提供了动力,使其成为Java中最流行的多线程习惯用法之一(如果不是)。 它也是我们使用的许多更复杂模式的基础,例如线程和连接池,并发集合等等。
synced关键字在两个主要上下文中使用:
- 作为方法修饰符,用于标记一种方法,该方法一次只能由一个线程执行。
- 通过将代码块声明为关键部分 –在任何给定时间点仅一个线程可以使用一个代码块。
锁定说明
事实1 。 同步代码块使用两个专用字节码指令实现,这是官方规范的一部分-MonitorEnter和MonitorExit 。 这与其他锁定机制不同,例如在java.util.concurrent包中找到的那些锁定机制,这些锁定机制是结合Java代码和通过sun.misc.Unsafe进行的本机调用实现的(对于HotSpot而言)。
这些指令对开发人员在同步块的上下文中明确指定的对象进行操作。 对于同步方法,锁定将自动选择为“ this ”变量。 对于静态方法,锁将放置在Class对象上。
同步方法有时会导致不良行为 。 一个示例是在相同对象的不同同步方法之间创建隐式依赖关系,因为它们共享相同的锁。 更糟糕的情况是在基类(甚至可能是第三方类)中声明同步方法,然后将新的同步方法添加到派生类。 这会在整个层次结构中创建隐式同步依赖关系,并有可能导致吞吐量问题甚至死锁。 为避免这些情况,建议使用私有对象作为锁,以防止意外共享或逃脱锁。
编译器和同步
有两个字节码指令负责同步。 这是不寻常的,因为大多数字节码指令彼此独立,通常通过将值放在线程的操作数堆栈上来彼此“通信”。 还可以从操作数堆栈中加载要锁定的对象,该操作数堆栈先前是通过取消引用变量,字段或调用返回对象的方法来放置的。
事实2。 那么,如果在没有分别调用另一条指令的情况下调用了两条指令之一,会发生什么呢? 如果不调用MonitorEnter,Java编译器将不会生成调用MonitorExit的代码。 即使这样,从JVM的角度来看,这样的代码也是完全有效的。 这种情况的结果是MonitorExit指令抛出IllegalMonitorStateException。
如果通过MonitorEnter获得锁但没有通过对MonitorExit的相应调用释放锁,将会发生更危险的情况 。 在这种情况下,拥有该锁的线程可能导致其他试图获取该锁的线程无限期地阻塞。 值得注意的是,由于锁是可重入的,因此拥有该锁的线程可能会继续愉快地执行,即使它再次到达并重新输入相同的锁也是如此。
这就是陷阱。 为了防止这种情况的发生,Java编译器以这种方式生成匹配的输入和退出指令,即一旦执行已进入同步块或方法,它就必须通过匹配的MonitorExit指令来处理同一对象。 可能会引起麻烦的一件事是,如果关键部分抛出异常。
public void hello() {synchronized (this) {System.out.println("Hi!, I'm alone here");}
}
让我们分析一下字节码–
aload_0 //load this into the operand stack
dup //load it again
astore_1 //backup this into an implicit variable stored at register 1
monitorenter //pop the value of this from the stack to enter the monitor//the actual critical section
getstatic java/lang/System/out Ljava/io/PrintStream;
ldc "Hi!, I'm alone here"
invokevirtual java/io/PrintStream/println(Ljava/lang/String;)Vaload_1 //load the backup of this
monitorexit //pop up the var and exit the monitor
goto 14 // completed - jump to the end// the added catch clause - we got here if an exception was thrown -
aload_1 // load the backup var.
monitorexit //exit the monitor
athrow // rethrow the exception object, loaded into the operand stack
return
编译器用来防止栈不展开而无需通过MonitorExit指令的机制非常简单–编译器添加了一个隐式的try…catch子句以释放锁并重新抛出异常。
事实三 。 另一个问题是在相应的enter和exit调用之间存储的对锁定对象的引用在哪里。 请记住,多个线程可能会使用不同的锁定对象同时执行同一同步块。 如果锁定的对象是调用方法的结果,则JVM极不可能再次执行它,因为它可能会更改对象的状态,甚至可能不会返回相同的对象。 对于自输入监视器以来可能已更改的变量或字段,情况也是如此。
监视变量 。 为了解决这个问题,编译器将一个隐式局部变量添加到方法中,以保存锁定对象的值。 这是一个聪明的解决方案,因为与使用并发堆结构将锁定对象映射到线程(该结构本身可能需要同步)相比,该方法在维护对锁定对象的引用上的开销非常小。 在构建Takipi的堆栈分析算法时,我首先观察到了这个新变量,并发现代码中弹出了意外变量。
注意,所有这些工作都是在Java编译器级别完成的。 JVM非常乐意通过MonitorEnter指令进入关键部分而不退出(反之亦然),或将不同的对象用作对应的enter和exit方法。
锁定在JVM级别
现在让我们更深入地研究如何在JVM级别上实际实现锁。 为此,我们将研究HotSpot SE 7的实现,因为它是VM特定的。 由于锁定可能会对代码吞吐量产生一些不利影响,因此JVM进行了一些非常强大的优化,以使获取和释放锁定的效率尽可能高。
事实#4。 JVM所采用的最强大的机制之一是线程锁偏置 。 锁定是每个Java对象都具有的一种固有功能,就像具有系统哈希码或对其定义类的引用一样。 无论对象的类型如何,都是如此(如果需要,您甚至可以使用基本数组作为锁)。
这些类型的数据存储在每个对象的标头(也称为对象的标记)中。 保留在对象标题中的某些数据保留用于描述对象的锁定状态。 这包括描述对象的锁定状态(即锁定/解锁)的位标志,以及对当前拥有该锁的线程的引用-指向该对象的线程有偏。
为了节省对象标头中的空间,为了减少地址大小并节省每个对象标头中的位(64位和32位JVM为54位或23位),在VM堆的较低段中分配了Java线程对象。分别)。
对于64位–
锁定算法
当JVM尝试获取对象的锁时,它会经历从乐观到悲观的一系列步骤。
事实五。 如果线程成功将其自身确立为对象锁的所有者,则该线程将获取该锁。 这取决于线程是否能够在对象的头中安装对自身的引用(指向内部JavaThread对象的指针)。
获取锁。 使用简单的比较交换(CAS)操作即可完成此操作。 这非常有效,因为它通常可以转换为直接CPU指令(例如cmpxchg)。 CAS操作与OS特定的线程驻留例程一起用作对象同步习惯用法的构建块。
如果该锁是免费的,或者先前已对该线程进行了预紧,则该线程将获得对象的锁,并且可以立即继续执行。 如果CAS失败,则JVM将执行一轮自旋锁定,在该循环中线程停放以有效地使其在重试CAS之间进入睡眠状态。 如果这些初始尝试失败(向锁发出更高级别的争用信号),线程将自身进入阻塞状态,并将自己排入争用该锁的线程列表,并开始一系列自旋锁。
在每轮旋转之后,线程将检查JVM全局状态的变化,例如“停止世界” GC的出现,在这种情况下,线程将需要暂停自身直到GC完成以防止出现这种情况。在执行STW GC时获得锁并继续执行的位置。
释放锁。 当通过MonitorExit指令退出关键部分时,所有者线程将尝试查看它是否可以唤醒任何正在等待释放锁的驻留线程。 此过程称为选择“继承人”。 这是为了增加活动性,并防止在释放锁的情况下仍保持线程驻留(也称为绞合)的情况。
调试服务器多线程问题很困难,因为它们往往取决于非常特定的时间安排和操作系统启发。 这就是让我们首先致力于Takipi的原因之一。
翻译自: https://www.javacodegeeks.com/2013/08/5-things-you-didnt-know-about-synchronization-in-java-and-scala.html