JAVA并发编程学习笔记之synchronized
监视器
Java 中的同步是通过监视器(Monitor)模型来实现的。Java 中的监视器实质是一个代码块,这段代码块在同一时刻只允许被一个线程执行。线程要想执行这段代码块,唯一的方式是获得监视器。
监视器支持两种同步方式:互斥与协作。
- 互斥:多线程环境下,如果线程之间需要共享数据,必须解决互斥访问的问题。监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
- 协作:某些场景需要线程间协作。例如,一个线程向缓冲区写数据,另一个线程从缓冲区读数据。如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据后,会唤醒读线程。这里读线程和写线程就是一种合作关系。
JVM 通过 Object 类的 wait 方法来使线程等待。在调用 wait 方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用 notify 方法通知正在等待的线程,但被唤醒的线程并不会马上执行,而是要等到通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object 类中的 notifyAll 方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程能获得执行机会。
如上图所示,一个线程通过 1 号门进入 Entry Set(入口区)。如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的 Owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。
线程在持有监视器的过程中,有两个选择:
- 正常执行监视器区域的代码,释放监视器,通过 5 号门退出监视器。
- 等待某个条件的出现,于是它会通过 3 号门到 Wait Set(等待区) 休息,直到相应的条件满足后再通过 4 号门进入,重新获取监视器再执行。
注意:当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器。如果入口区的线程赢了,会从 2 号门进入;如果等待区的线程赢了,会从 4 号门进入。只有通过 3 号门才能进入等待区,在等待区中的线程只有通过 4 门才能退出等待区。也就是说,一个线程只有在持有监视器时才能执行 wait 操作,处于等待的线程只有再次获得监视器才能退出等待状态。对象锁
JVM 中的一些数据,比如堆和方法区,会被所有线程共享。Java 中每个对象和类实际上都有一把锁与之相关联:
- 对于对象来说,监视的是这个对象的实例变量。
- 对于类来说,监视的是类变量。如果一个对象没有实例变量,就什么也不监视。
当虚拟机装载类时,会创建一个 Class 类的实例,锁住一个类实际上锁住的是这个类对应的 Class 类的实例。对象锁是可重入的,也就是说对一个对象或者类上的锁可以累加。
在 Java 中有两种监视区域:同步方法和同步块。这两种监视区域都和一个引用对象相关联。当到达这个监视区域时,JVM 就会锁住这个引用对象;不论它是怎么离开的,都会释放这个引用对象上的锁。Java 程序员不能自己手动加对象锁,对象锁是 JVM 内部机制,只需要编写同步方法或者同步块即可,操作监视区域时 JVM 会自动帮你上锁或者释放锁。
同步语句
要建立一个同步语句,只需要在相关语句加上 synchronized 关键字就可以。例如下面的 incr 方法,如果没有获得当前对象(this)的锁,在同步块内的语句是不会执行的。如果不是 this 引用,而是用另一个对象的引用,需要获得对应对象的锁同步块才会执行;如果用表达式获得对 Class 对象实例的引用,就需要锁住那个类。
void incr() {
synchronized (this) {
i++;
}
}以下是 incr 方法生成的字节码序列:
void incr();
Code:
0: aload_0 // 将 this 引用压栈
1: dup // 复制栈顶元素
2: astore_1 // 出栈并将 this 引用存放在局部变量 1 中
3: monitorenter // 出栈并获取对象锁
4: aload_0 // 将 this 引用压栈
5: dup // 复制栈顶元素
6: getfield #17 // 获取 i 的值
9: iconst_1 // 常数 1 入栈
10: iadd // 将 i+1 的结果入栈
11: putfield #17 // 将 i 的值存入 this 中
14: aload_1 // 将 this 引用压栈
15: monitorexit // 弹出 this 引用释放对象锁
16: goto 22 // 返回
19: aload_1 // 19-22 如果抛出,释放对象锁
20: monitorexit
21: athrow
22: return
Exception table:
from to target type
4 16 19 any
19 21 19 any字节码的第 3 行从栈顶中获取对象锁,对象锁获取成功后才执行后面的 add 操作,第 15 行释放获取的对象锁。注意:字节码中出现了异常表,是用于确保加锁的对象被释放。即使从同步语句块中抛出异常,也会释放对象锁,否则有可能导致死锁。
同步方法
要建立同步方法,只需要在方法修饰符前加上 synchronized 关键字,类似代码如下:
synchronized void incr() {
i++;
}生成的字节码序列如下:
synchronized void incr();
Code:
0: aload_0 // this 引用压栈
1: dup // 复制栈顶元素
2: getfield #2 // 获取 i 的值
5: iconst_1 // 将常量 1 入栈
6: iadd // i+1 入栈
7: putfield #2 // 将 i 的值存入 this 中
10: return // 返回可见,JVM 并没有使用 monitorenter 和 monitorexit 等指令。查看 class 文件在方法表中可以看到有 0020 出现,这是 incr 方法的访问标志(access flag):ACC_SYNCHRONIZED。顾名思义,这说明 incr 是一个线程同步方法。当 JVM 发现这是一个同步方法时,就会在这个对象或者类上获取锁,退出方法时会释放这个锁。
两段字节码除了调用指令不同,还有一个区别是同步方法可以没有异常表,实际上 JVM 隐式地做了异常处理。
优缺点
synchronized 是通过软件(JVM)实现的,简单易用。即使在 JDK 5 之后有了 Lock,synchronized 仍然被广泛地使用。
优势
- 简单易用:无需手动管理锁的获取与释放。
- 防止饥饿:
synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待。不过这种抢占的方式在一定程度上可以预防饥饿。
局限性
- 灵活性不足:
synchronized只有锁只与一个条件(是否获取锁)相关联。后来Condition与Lock的结合解决了这个问题。 - 无法响应中断:多线程竞争一个锁时,其余未得到锁的线程只能不停地尝试获得锁,而不能中断。高并发的情况下会导致性能下降。
ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样容易产生死锁。
参考资料
- Java 多线程总结之由 synchronized 说开去
- 深入 JVM 锁机制 1-synchronized
- 深入 JVM 锁机制 2-Lock
- The JavaTM Virtual Machine Specification
- Inside the Java Virtual Machine
说明:本文内容基于 JDK 5/6 时期的锁机制原理整理。自 JDK 6 起,synchronized 引入了偏向锁、轻量级锁等优化机制,性能已有显著提升,但底层监视器模型基本原理保持一致。 版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/java-bing-fa-bian-cheng-xue-xi-bi-ji-zhi-synchronized.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。