问题引出

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,观察结果

  1. static int count = 0;
  2. //两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,观察结果
  3. public static void main(String[] args) throws InterruptedException {
  4. Thread t1 = new Thread(() -> {
  5. for (int i = 0; i < 5000; i++) {
  6. count++;
  7. }
  8. }, "t1");
  9. Thread t2 = new Thread(() -> {
  10. for (int i = 0; i < 5000; i++) {
  11. count--;
  12. }
  13. }, "t2");
  14. t1.start();
  15. t2.start();
  16. //等待t1执行完成
  17. t1.join();
  18. //等待t2执行完成
  19. t2.join();
  20. log.info("count is {}", count);
  21. }
  • 分析

以上的结果可能是正数、负数、零。
在 Java 中对静态变量的自增,自减并不是原子操作,例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令(idea安装jclasslib Bytecode viewer工具即可查看字节码):

  1. LINENUMBER 19 L3
  2. //获取静态变量count的值
  3. GETSTATIC com/song/share/ShareProblem.count : I
  4. //准备常量1
  5. ICONST_1
  6. //自增
  7. IADD
  8. //将修改后的值存入静态变量count
  9. PUTSTATIC com/song/share/ShareProblem.count : I
  10. LINENUMBER 24 L3
  11. //获取静态变量count的值
  12. GETSTATIC com/song/share/ShareProblem.count : I
  13. //准备常量1
  14. ICONST_1
  15. //自减
  16. ISUB
  17. //将修改后的值存入静态变量count
  18. PUTSTATIC com/song/share/ShareProblem.count : I

Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换
image.png
单线程情况下不会出问题,但是多线程时会造成共享变量读写冲突,两个线程都对一个变量进行修改,导致后修改的线程覆盖了前面线程的值。

临界区 Critical Section


一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源
在多个线程对共享资源读写操作时发生指令交错,就会出现问题。一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。例如,下面代码中的临界区 。

  1. static int counter = 0;
  2. static void increment()
  3. // 临界区
  4. {
  5. counter++;
  6. }
  7. static void decrement()
  8. // 临界区
  9. {
  10. counter--;
  11. }

竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

synchronized

为了避免临界区的竞态条件发生,有多种手段可以达到目的。阻塞式的解决方案:synchronized,Lock;非阻塞式的解决方案:原子变量 (CAS)
synchronized它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁
的线程可以安全的执行临界区内的代码,不用担心线程上下文切换 。
备注:互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码

案例分析

synchronized实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
但是多个线程对同一个共享变量的临界区必须使用同一把锁进行控制,案例中的t1如果和t2加的锁不一致则结果也不对,或者t1加锁t2不加锁也会导致结果异常。

  1. static int count = 0;
  2. static final Object lock = new Object();
  3. public static void main(String[] args) throws InterruptedException {
  4. Thread t1 = new Thread(() -> {
  5. for (int i = 0; i < 5000; i++) {
  6. //加锁
  7. synchronized (lock) {
  8. count++;
  9. }
  10. }
  11. }, "t1");
  12. Thread t2 = new Thread(() -> {
  13. for (int i = 0; i < 5000; i++) {
  14. //加锁
  15. synchronized (lock) {
  16. count--;
  17. }
  18. }
  19. }, "t2");
  20. t1.start();
  21. t2.start();
  22. t1.join();
  23. t2.join();
  24. log.info("count is {}", count);
  25. }



image.png

不同位置的synchronize

synchronize关键字可以加载静态方法上也可以加在普通方法上

  1. // synchronized 加在普通方法上,等价于锁住当前对象
  2. public synchronized void increment() {
  3. count++;
  4. }
  5. //等价于
  6. public void increment() {
  7. synchronized (this) {
  8. count++;
  9. }
  10. }
  11. //synchronized 加在静态方法上,等价于锁住当前类
  12. public synchronized static void increment() {
  13. count++;
  14. }
  15. //等价于
  16. public void increment() {
  17. synchronized (Room.class) {
  18. count++;
  19. }
  20. }

对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充,其中Java头对象则是实现synchronized的锁对象的基础
image.png

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

    对象头格式

    JVM中对象头的方式有以下两种,以32位虚拟机为代表,32位虚拟机对象头占8个字节(64bit),数组对象占12个字节(96bit),64位虚拟机普通对象头占16个字节(128bit),数组对象占20个字节(160bit),除占用长度不一样之外其他结构类似。

  • 普通对象

    1. |--------------------------------------------------------------|
    2. | Object Header (64 bits) |
    3. |------------------------------------|-------------------------|
    4. | Mark Word (32 bits) | Klass Word (32 bits) |
    5. |------------------------------------|-------------------------|
  • 数组对象

    1. |---------------------------------------------------------------------------------|
    2. | Object Header (96 bits) |
    3. |--------------------------------|-----------------------|------------------------|
    4. | Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
    5. |--------------------------------|-----------------------|------------------------|

    对象头组成

    Mark Word

    MarkWord主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32位,64位JVM为64位。
    为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word状态如下:

    1. |-------------------------------------------------------|--------------------|
    2. | Mark Word (32 bits) | State |
    3. |-------------------------------------------------------|--------------------|
    4. | identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
    5. |-------------------------------------------------------|--------------------|
    6. | thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
    7. |-------------------------------------------------------|--------------------|
    8. | ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
    9. |-------------------------------------------------------|--------------------|
    10. | ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
    11. |-------------------------------------------------------|--------------------|
    12. | | lock:2 | Marked for GC |
    13. |-------------------------------------------------------|--------------------|

    其中各部分的含义如下:重点关注 lockbiased_lock
    lock: 2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。

biased_lock lock 状态
0 01 无锁
1 01 偏向锁
0 00 轻量级锁
0 10 重量级锁
0 11 GC标记

biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
thread:持有偏向锁的线程ID。
epoch:偏向时间戳。
ptr_to_lock_record:指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:指向管程Monitor的指针。

  1. |-------------------------------------------------------|--------------------|
  2. | Mark Word (32 bits) | State |
  3. |-------------------------------------------------------|--------------------|
  4. | hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
  5. |-------------------------------------------------------|--------------------|
  6. | thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
  7. |-------------------------------------------------------|--------------------|
  8. | ptr_to_lock_record:30 | 00 | Lightweight Locked |
  9. |-------------------------------------------------------|--------------------|
  10. | ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
  11. |-------------------------------------------------------|--------------------|
  12. | | 11 | Marked for GC |
  13. |-------------------------------------------------------|--------------------|

Monitor概念

每个java对象的对象头中,都有锁标识,并且Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
Monitor关键属性
_owner:指向持有Monitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
_recursions:锁的重入次数
_cxq: 多个线程争抢锁,会先存入这个单向链表
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)

  • 图解

image.png

  • 刚开始Monitor中Owner为null
  • 当Thread-2执行 synchronized(obj)就会将Monitor的所有者Owner置为 Thread-2,Monitor中只能有一个Owner
  • 在 Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争的时是非公平的
  • 图中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程,后面讲wait-notify时会分析

备注:
synchronized 必须是进入同一个对象的 monitor 才有上述的效果
不加 synchronized 的对象不会关联监视器,不遵从以上规则

synchronized 原理

  1. public class SynchronizePrinciple {
  2. static final Object lock = new Object();
  3. public static void main(String[] args) {
  4. synchronized (lock) {
  5. System.out.println(11);
  6. }
  7. }
  8. }
  9. public static void main(java.lang.String[]);
  10. descriptor: ([Ljava/lang/String;)V
  11. flags: ACC_PUBLIC, ACC_STATIC
  12. Code:
  13. stack=2, locals=3, args_size=1
  14. 0: getstatic #2 // Field lock: <- lock引用 (synchronized开始)
  15. 3: dup
  16. 4: astore_1 // <- lock引用 (synchronized开始)
  17. 5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
  18. 6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
  19. 9: bipush 11
  20. 11: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
  21. 14: aload_1
  22. 15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
  23. 16: goto 24
  24. 19: astore_2 // e -> slot 2 下面的指令是发生异常的情况
  25. 20: aload_1 // <- lock引用
  26. 21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
  27. 22: aload_2 // <- slot 2 (e)
  28. 23: athrow // throw e
  29. 24: return
  30. Exception table:
  31. from to target type
  32. 6 16 19 any
  33. 19 22 19 any
  34. LineNumberTable:
  35. line 15: 0
  36. line 16: 6
  37. line 17: 14
  38. line 19: 24
  39. LocalVariableTable:
  40. Start Length Slot Name Signature
  41. 0 25 0 args [Ljava/lang/String;
  42. StackMapTable: number_of_entries = 2
  43. frame_type = 255 /* full_frame */
  44. offset_delta = 19
  45. locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
  46. stack = [ class java/lang/Throwable ]
  47. frame_type = 250 /* chop */
  48. offset_delta = 4

**monitorenter**:每个对象都有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;

**monitorexit**:执行monitorexit的线程必须是lock所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit指令出现了两次,第1次为正常退出释放锁;第2次为发生异常退出释放锁;