背景介绍

下面这段代码是2个线程同时对一个共享变量count进行++操作,每个线程都加1000次。但是运行出来的结果并不一定是2000,而是小于2000的数。这是因为++操作并不是一个原子的操作,++操作主要分为读取数据,修改数据和写回数据,所以在并发情况下会存在问题。此时我们只需要在++的方法上加上synchronized即可实现同步

  1. public class SynchronizedTest {
  2. private static Integer count = 0;
  3. public static void main(String[] args) throws InterruptedException {
  4. new Thread(() -> {
  5. for (int i = 0; i < 1000; i++) {
  6. addOne();
  7. }
  8. }).start();
  9. new Thread(() -> {
  10. for (int i = 0; i < 1000; i++) {
  11. addOne();
  12. }
  13. }).start();
  14. Thread.sleep(1000);
  15. System.out.println(count);
  16. }
  17. public static void addOne() {
  18. count++;
  19. }
  20. }

原因描述

序号 线程1 线程2
1 读取count值为0
2 读取count值为0
3 count = count + 1 = 1
4 count = count + 1 = 1
5 写回count = 1
6 写回count = 1

解决方法:通过synchronized同步

  1. public synchronized static void addOne() {
  2. count++;
  3. }

锁分类

对象锁

  1. 在同步代码块中锁的是小括号中的实例对象:synchronized(this),synchronized(实例对象)
  2. 同步非静态方法,锁是当前对象实例

    类锁(只有一份)

  3. 在同步代码块中锁是小括号中的类对象(class对象):synchronized(类.class)

  4. 同步静态方法:锁是当前对象的类对象(class对象)

    加锁原理

    字节码分析

    下面这个段代码中有2个方法,第一个使用synchronized块,第二个使用synchronized修饰方法

    1. public class SyncCode {
    2. public void testSync() {
    3. synchronized (this) {
    4. System.out.println("hello");
    5. }
    6. }
    7. public synchronized void testS() {
    8. System.out.println("hello");
    9. }
    10. }

    synchronized块通过monitorenter(第9行代码)和monitorexit(第14和18行代码)来进行同步。monitorenter代表进入同步代码块并对对象上锁,monitorexit代表退出同步代码块并释放锁,字节码中有2个monitorexit分别代表方法正常执行完成和发生异常时都需要释放锁

    1. public void testSync();
    2. descriptor: ()V
    3. flags: ACC_PUBLIC
    4. Code:
    5. stack=2, locals=3, args_size=1
    6. 0: aload_0
    7. 1: dup
    8. 2: astore_1
    9. 3: monitorenter
    10. 4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
    11. 7: ldc #3 // String hello
    12. 9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    13. 12: aload_1
    14. 13: monitorexit
    15. 14: goto 22
    16. 17: astore_2
    17. 18: aload_1
    18. 19: monitorexit
    19. 20: aload_2
    20. 21: athrow
    21. 22: return
    22. Exception table:
    23. from to target type
    24. 4 14 17 any
    25. 17 20 17 any
    26. LineNumberTable:
    27. line 9: 0
    28. line 10: 4
    29. line 11: 12
    30. line 12: 22
    31. StackMapTable: number_of_entries = 2
    32. frame_type = 255 /* full_frame */
    33. offset_delta = 17
    34. locals = [ class com/arthur/multiThread/SyncCode, class java/lang/Object ]
    35. stack = [ class java/lang/Throwable ]
    36. frame_type = 250 /* chop */
    37. offset_delta = 4

    同步方法是通过ACC_SYNCHRONIZED字段在方法级别上进行加锁

    1. public synchronized void testS();
    2. descriptor: ()V
    3. flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    4. Code:
    5. stack=2, locals=1, args_size=1
    6. 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
    7. 3: ldc #3 // String hello
    8. 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    9. 8: return
    10. LineNumberTable:
    11. line 15: 0
    12. line 16: 8
    13. }

    原理分析

    下面同步代码块中用实例对象myLock作为锁对象 ```java private static MyLock myLock = new MyLock(); synchronized (myLock) {

}

  1. 当多个线程并发争抢锁的时候,会进入myLock对象中的entryList中,此时会通过cas尝试加锁,如果加锁成功就会将owner设置为当前线程<br />![截屏2020-12-20 下午4.50.28.png](https://cdn.nlark.com/yuque/0/2020/png/492083/1608454231248-181f9f2a-f7cc-47cb-8714-e902f1fdec5a.png#align=left&display=inline&height=578&margin=%5Bobject%20Object%5D&name=%E6%88%AA%E5%B1%8F2020-12-20%20%E4%B8%8B%E5%8D%884.50.28.png&originHeight=1156&originWidth=1994&size=250481&status=done&style=none&width=997)
  2. <a name="yHZ0Q"></a>
  3. ## 锁优化
  4. JDK 1.6 之后JVM对锁的机制做了很大的优化。锁膨胀:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁<br />一个java对象中包含的字段<br />![](https://cdn.nlark.com/yuque/0/2020/png/492083/1608471937859-b34efa73-996b-4509-ae46-d30d5e075a5b.png#align=left&display=inline&height=272&margin=%5Bobject%20Object%5D&originHeight=272&originWidth=581&size=0&status=done&style=none&width=581)<br />32位虚拟机中对象头中字段如下<br />![](https://cdn.nlark.com/yuque/0/2020/png/492083/1608471912972-59531800-0062-4a5e-b8b3-741ff819e8b9.png#align=left&display=inline&height=295&margin=%5Bobject%20Object%5D&originHeight=295&originWidth=858&size=0&status=done&style=none&width=858)
  5. <a name="M3PiE"></a>
  6. ### 锁消除
  7. 锁消除是JIT编译器对synchronized锁做的优化,在编译的时候,JIT会通过逃逸分析技术,来分析synchronized锁对象,是不是只可能被一个线程来加锁,没有其他的线程来竞争加锁,这个时候编译时就不需要加入monitorentermonitorexit执行。例如下面代码中,StringBuffer是一个线程安全的类,其同步机制是靠synchronized实现的。但是下面的代码由于StringBuffer对象是一个局部变量,所以并不存在并发问题,JIT会在编译期去掉同步逻辑
  8. ```java
  9. public class SynTest {
  10. public static void main(String[] args) {
  11. StringBuffer stringBuffer = new StringBuffer();
  12. for (int i = 0; i < 100; i++) {
  13. stringBuffer.append("test");
  14. }
  15. }
  16. }

锁粗化

JIT编译器如果发现有代码里连续多次加锁释放锁的代码,会合并为一个锁,避免频繁多次加锁释放锁。下面代码中不是每次调用append方法时才上锁,而是只加一次锁,编译器通过增加锁的范围,扩大到append方法外部,从而避免频繁加锁和解锁

private static StringBuffer sb = new StringBuffer();
public static String copyString(String target) {
    int i = 0;
    while (i < 100) {
        sb.append(target);
    }
    return sb.toString();
}

偏向锁

通过monitorenter和monitorexit需要使用CAS操作进行加锁和释放锁,开销较大。特别是对于只有一个线程频繁加锁加锁的场景时,这些操作是没有必要的。所以当线程第一次抢到锁的时候,会在锁对象的对象头中记录线程ID,当下次本线程再执行时只需要判断锁对象头中的偏向锁记录的线程ID是当前线程时就不需要通过CAS去抢占锁。例如下列代码中,当线程第一次循环通过CAS的方式对lock对象上锁,并在lock对象的对象头中记录下当前的线程id,当第二次循环时,判断lock对象的偏向锁记录的线程ID是当前线程时,就不会再通过CAS去抢占锁。如果发现当前抢占资源的线程与偏向锁中的线程ID不一致,就会升级为轻量级锁

public class SynTest {

    private static Integer count = 0;

    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                System.out.println("线程1执行 i:" + i);
                add();
            }
        }).start();
        Thread.sleep(1000);
        System.out.println(count);
    }

    public static void add() {
        synchronized (lock) {
            count++;
        }
    }

}

轻量级锁

  1. 当线程获取到锁时,会在当前线程的栈中建立一个名为Lock Record空间
  2. 获取对象头中的Mark Record字段,比较Mark Record中的信息是否与Lock Record中的一致
  3. 一致,将对象头中的Mark Record字段指向当前线程并且将锁标志位设置成轻量级锁
  4. 不一致,说明有其他线程在操作,升级为重量级锁

上述过程中的2、3是原子操作(CAS),由操作系统保证

自旋锁、自适应自旋锁

当升级为重量级锁后,没有获取到锁的线程,就会让cpu进行上下文切换,这个cpu上下文切换的开销比较大。此时JVM会让线程自旋一段时间(忙循环,while循环),不让出CPU资源。JVM通过前一次同一个锁上的自旋时间和锁的拥有者的状态决定自旋的时间和次数