关于 Java 和 Scala 同步你不知道的 5 件事
实际上,所有服务器应用程序都需要在多个线程之间进行某种同步。大多数同步工作是在框架级别为我们完成的,例如通过我们的 Web 服务器、数据库客户端或消息传递框架。Java 和 Scala 提供了大量组件来编写可靠的多线程应用程序。其中包括对象池、并发集合、高级锁、执行上下文等。
为了更好地理解这些,让我们探讨一下最常见的同步习惯——对象锁。这种机制为synchronized关键字提供了支持,使其成为Java中最流行的多线程习惯用法之一。它也是我们使用的许多更复杂模式的基础,例如线程和连接池、并发集合等等。
同步关键字用于两个主要上下文:
- 作为方法修饰符,标记一个方法一次只能由一个线程执行。
- 通过将代码块声明为关键部分——在任何给定时间点仅可用于单个线程的代码块。
锁定说明
事实#1
同步代码块是使用两个专用字节码指令实现的,它们是官方规范的一部分 - MonitorEnter 和 MonitorExit。这与其他锁定机制不同,例如 java.util.concurrent 包中的锁定机制,后者是使用 Java 代码和通过 sun.misc.Unsafe 进行的本机调用的组合来实现的(在 HotSpot 的情况下)。
这些指令对开发人员在同步块上下文中显式指定的对象进行操作。对于同步方法,锁被自动选择为“this”变量。对于静态方法,锁将放置在 Class 对象上。
同步方法有时会导致不良行为。一个示例是在同一对象的不同同步方法之间创建隐式依赖关系,因为它们共享相同的锁。更糟糕的情况是在基类(甚至可能是第三方类)中声明同步方法,然后向派生类添加新的同步方法。这会在层次结构中创建隐式同步依赖性,并有可能造成吞吐量问题甚至死锁。为了避免这些情况,建议使用私有对象作为锁,以防止意外共享或逃脱锁。
[代理组=”11”]
编译器和同步
有两条字节码指令负责同步。这是不寻常的,因为大多数字节码指令是相互独立的,通常通过将值放在线程的操作数堆栈上来相互“通信”。要锁定的对象也是从操作数堆栈加载的,之前通过取消引用变量、字段或调用返回对象的方法将其放置在那里。
事实#2
那么,如果调用两条指令之一而没有分别调用另一条指令,会发生什么情况呢?Java 编译器不会生成在不调用 MonitorEnter 的情况下调用 MonitorExit 的代码。即便如此,从 JVM 的角度来看,这样的代码是完全有效的。这种情况的结果是 MonitorExit 指令抛出 IllegalMonitorStateException。
更危险的情况是,如果通过 MonitorEnter 获取锁,但未通过相应的 MonitorExit 调用释放锁,则会发生什么情况。在这种情况下,拥有锁的线程可能会导致其他尝试获取锁的线程无限期地阻塞。值得注意的是,由于锁是可重入的,因此拥有锁的线程可以继续愉快地执行,即使它再次到达并重新进入同一个锁。
这就是问题所在。为了防止这种情况发生,Java 编译器生成匹配的进入和退出指令,一旦执行进入同步块或方法,它必须为同一对象传递匹配的 MonitorExit 指令。可能会造成麻烦的一件事是,如果在临界区内抛出异常。
[java]
public void hello() {
synchronized(this) {
System.out.println(“嗨!,我一个人在这里”);
}
}
[/java]
让我们分析一下字节码——
[java]
aload_0 //将其加载到操作数堆栈中
dup //再次加载
astore_1 //将其备份到存储在寄存器1的隐式变量中
monitorenter //将其从堆栈中弹出以进入监视器
//实际值临界区
getstatic java/lang/System/out Ljava/io/PrintStream;
ldc “嗨!,我一个人在这里”
invokevirtual java/io/PrintStream/println(Ljava/lang/String;)V
aload_1 //加载此
监视器的备份exit //弹出var并退出监视器
goto 14 / / Completed – 跳转到末尾
// 添加的 catch 子句 – 如果抛出异常,我们就会到达这里 –
aload_1 // 加载备份变量。
monitorexit //退出监视器
athrow //重新抛出异常对象,加载到操作数栈
return
[/java]
编译器用来防止堆栈在不通过 MonitorExit 指令的情况下展开的机制非常简单 - 编译器添加一个隐式的 try…catch 子句来释放锁并重新抛出异常。
事实#3
另一个问题是在相应的进入和退出调用之间存储的锁定对象的引用在哪里。请记住,多个线程可以使用不同的锁对象同时执行同一个同步块。如果锁定的对象是调用方法的结果,则 JVM 不太可能再次执行它,因为它可能会更改对象的状态,或者甚至可能不会返回相同的对象。对于自进入监视器以来可能已更改的变量或字段也是如此。
监控变量。为了解决这个问题,编译器向方法添加一个隐式局部变量来保存锁定对象的值。这是一个聪明的解决方案,因为它在维护对锁定对象的引用方面施加了相当小的开销,而不是使用并发堆结构将锁定对象映射到线程(该结构本身可能需要同步)。我第一次观察到这个新变量是在构建 OverOps 的堆栈分析算法时,发现代码中突然出现了意外的变量。
请注意,所有这些工作都是在 Java 编译器级别完成的。JVM 非常乐意通过 MonitorEnter 指令进入关键部分而不退出它(反之亦然),或者使用不同的对象来执行相应的进入和退出方法。
JVM 级别的锁定
现在让我们更深入地了解锁在 JVM 级别的实际实现方式。为此,我们将检查 HotSpot SE 7 实现,因为这是特定于 VM 的。由于锁定可能会对代码吞吐量产生一些相当不利的影响,因此 JVM 已经实施了一些非常强大的优化,以使获取和释放锁尽可能高效。
事实#4。JVM 采用的最强大的机制之一是线程锁偏向。锁定是每个 Java 对象都具有的内在功能,就像拥有系统哈希码或对其定义类的引用一样。无论对象的类型如何,都是如此(如果您愿意,您甚至可以使用原始数组作为锁)。
这些类型的数据存储在每个对象的标头(也称为对象的标记)中。放置在对象标头中的一些数据被保留用于描述对象的锁定状态。这包括描述对象锁定状态(即锁定/解锁)的位标志以及对当前拥有锁的线程的引用——该线程对对象是有偏向的。
为了节省对象头内的空间,Java 线程对象被分配在 VM 堆的较低段中,以减少地址大小并节省每个对象头内的位(64 和 32 位 JVM 为 54 或 23 位)分别)。
锁定算法
当 JVM 尝试获取对象上的锁时,它会经历一系列从乐观到悲观的步骤。
事实#5
如果线程成功将自己确立为对象锁的所有者,则该线程将获取锁。这取决于线程是否能够在对象头中安装对其自身的引用(指向内部 JavaThread 对象的指针)。
首次尝试使用简单的比较和交换 (CAS) 操作来完成此操作。这是非常高效的,因为它通常可以转换为直接的 CPU 指令(例如 cmpxchg)。CAS 操作与操作系统特定的线程停放例程一起充当对象同步惯用语的构建块。
如果锁是空闲的或者之前已偏向该线程,则该线程将获得对象上的锁,并且可以立即继续执行。如果 CAS 失败,JVM 将执行一轮自旋锁定,线程会在重试 CAS 之间有效地将其置于休眠状态。如果这些初始尝试失败(表明对锁的争用级别相当高),则线程会将自身移至阻塞状态,并将自己排入争夺锁的线程列表中,并开始一系列自旋锁。
释放锁。当通过 MonitorExit 指令退出临界区时,所有者线程将尝试查看是否可以唤醒任何可能正在等待锁释放的停放线程。这个过程被称为选择“继承人”。这是为了提高活跃度,并防止出现锁已释放时线程仍处于停放状态的情况(也称为搁浅)。
调试服务器多线程问题很困难,因为它们往往依赖于非常具体的计时和操作系统启发法。这是我们最初致力于 OverOps 的原因之一。
 
                    
                     
                    
                 
                    
                
 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号