synchronized详解

2023/12/17 Java多线程与并发

# 1、synchronized的特点

特点

  • synchronized是可重入锁、非公平锁、悲观锁、互斥锁(重量级锁情况)。
  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待。
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响。
  • 锁对象是类以或修饰的是static方法的时候,所有对象公用同一把锁。
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁。

注意点

  • 锁对象不能为空,因为锁的信息都保存在对象头里。
  • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错。
  • 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错。
  • 避免死锁情况:双重锁依赖会导致死锁、循环等待导致死锁。

# 2、synchronized使用分类

synchronized同步范围不同,可以将synchronized锁分为:对象锁和类锁。

# 2.1、对象锁

对象锁包含方法锁(默认锁对象为this,当前实例对象)和同步代码块锁(自己指定锁对象)。

  • 方法锁

synchronized修饰普通方法,锁对象默认为this。

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    public synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

/**
 * 输出结果:
 * 我是线程Thread-0
 * Thread-0结束
 * 我是线程Thread-1
 * Thread-1结束
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  • 同步代码块锁

手动指定锁定对象,也可是是this,也可以是自定义的锁。

指定this

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
        synchronized (this) {
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

/**
 * 输出结果:
 * 我是线程Thread-0
 * Thread-0结束
 * 我是线程Thread-1
 * Thread-1结束
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

指定对象

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();
    // 创建2把锁
    Object block1 = new Object();
    Object block2 = new Object();

    @Override
    public void run() {
        // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
        synchronized (block1) {
            System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
        }

        synchronized (block2) {
            System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

/**
 * 输出结果:
 * block1锁,我是线程Thread-0
 * block1锁,Thread-0结束
 * block2锁,我是线程Thread-0  // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
 * block1锁,我是线程Thread-1
 * block2锁,Thread-0结束
 * block1锁,Thread-1结束
 * block2锁,我是线程Thread-1
 * block2锁,Thread-1结束
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# 2.2、类锁

synchronize修饰静态的方法或指定锁对象为Class对象

synchronize修饰静态方法

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    // synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
    public static synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}

/**
 * 输出结果:
 * 我是线程Thread-0
 * Thread-0结束
 * 我是线程Thread-1
 * Thread-1结束
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

synchronized指定锁对象为Class对象

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 所有线程需要的锁都是同一把
        synchronized(SynchronizedObjectLock.class){
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}
/**
 * 输出结果:
 * 我是线程Thread-0
 * Thread-0结束
 * 我是线程Thread-1
 * Thread-1结束
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 3、synchronized原理解析

# 3.1、加锁和解锁原理解析

重量级锁:Monitor

在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,Monitor在Hotspot虚拟机(JDK中所带的虚拟机)中,Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的,有兴趣的可以去看看源码,这里只展示ObjectMonitor参数。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;         // 锁的计数器,获取锁时count数值加1,释放锁时count值减1,直到
    _waiters      = 0,         // 等待线程数
    _recursions   = 0;         // 锁的重入次数
    _object       = NULL; 
    _owner        = NULL;      // 指向持有ObjectMonitor对象的线程地址
    _WaitSet      = NULL;      // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;     // 阻塞在EntryList上的单向线程列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;     // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}  
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Monitor原理结构

解析过程

  • 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时锁计数器加1,若发现之前的owner的值就是指向当前线程的,锁重入次数加1。如果CAS尝试获取锁失败,则进入到EntryList中。
  • 当获取锁的线程调用wait()方法,则会将owner设置为null,同时锁计数器减1,锁重入次数减1,当前线程加入到WaitSet中,等待被唤醒。
  • 当前线程执行完同步代码块时,则会释放锁,锁计数器减1,锁重入次数减1。当锁计数器的值为0时,说明线程已经释放了锁。

synchronized锁原理

Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。

public void run() {
    synchronized (this){   //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
        for (int j = 0; j < 10000; j++) {
            i++;
        }
    }
}
1
2
3
4
5
6
7

查看代码字节码,主要看有注释部分:monitorenter、monitorexit

dup
astore_1
monitorenter     // 进入同步代码块的指令
iconst_0
istore_2
iload_2
sipush 10000
if_icmpge 27 (+17)
getstatic #2 <com/company/syncTest.i>
iconst_1
iadd
putstatic #2 <com/company/syncTest.i>
iinc 2 by 1
goto 6 (-18)
aload_1
monitorexit     //结束同步代码块的指令
goto 37 (+8)
astore_3
aload_1
monitorexit     //遇到异常时执行的指令
aload_3
athrow
return
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

Monitorenter和Monitorexit指令,会让对象在执行,使其锁计数器加1或者减1。

synchronized加锁原理

每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待。
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加。
  • 这把锁已经被别的线程获取了,等待锁释放。

synchronized解锁原理

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

# 3.2、可重入锁原理解析

  • 可重入:是指某个函数能被多个任务或者线程反复"安全"的调用,而不引发任何数据错误或其他预期之外的问题。
  • 可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

synchronized的重入性

在同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加一,释放锁后就会将monitor计数器减一,线程不需要再次获取同一把锁。

案例

public class SynchronizedDemo {

    public static void main(String[] args) {
        SynchronizedDemo demo =  new SynchronizedDemo();
        demo.method1();
    }

    private synchronized void method1() {
        System.out.println(Thread.currentThread().getId() + ": method1()");
        method2();
    }

    private synchronized void method2() {
        System.out.println(Thread.currentThread().getId()+ ": method2()");
        method3();
    }

    private synchronized void method3() {
        System.out.println(Thread.currentThread().getId()+ ": method3()");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

加锁解锁解析

monitorenter获取锁

  1. monitor计数器=0,可获取锁
  2. 执行method1()方法,monitor计数器+1 -> 1 (获取到锁)
  3. 执行method2()方法,monitor计数器+1 -> 2
  4. 执行method3()方法,monitor计数器+1 -> 3

monitorenter释放锁

  1. method3()方法执行完,monitor计数器-1 -> 2
  2. method2()方法执行完,monitor计数器-1 -> 1
  3. method2()方法执行完,monitor计数器-1 -> 0 (释放了锁)
  4. monitor计数器=0,锁被释放了

# 3.3、保证可见性原理解析

  • 内存屏障(Memory Barrier):在同步块的进入和退出时,会插入内存屏障(Memory Barrier),它会建立一个内存屏障,确保所有对共享变量的操作在退出同步块之前都会写回主内存中,而在进入同步块之前会从主内存中重新读取共享变量的值。
  • Happens-Before关系:在同一个线程中,按照程序的顺序,前一个操作对共享变量的修改在后续操作对共享变量的读取之前,建立了Happens-Before关系,这也确保了共享变量的可见性。这意味着在同一个线程中,即使没有加锁的代码块,也能够看到前一个操作对共享变量的修改。

# 4、JVM对synchronized锁优化

为什么要对synchronized锁优化?

  • Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。
  • 在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。

JVM对synchronized锁优化有哪些?

  • 增加自旋锁与自适应自旋锁
  • 锁消除
  • 锁粗化
  • 增加偏向锁
  • 增加轻量级锁

# 4.1、锁的等级

锁的等级总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁 (此过程是不可逆的)。

# 4.2、自旋锁与适应性自旋锁

自旋锁

  • 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
  • 在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
  • 而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。

适应性自旋锁

  • 自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
  • 自适应自旋锁即自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。

# 4.3、锁消除

当JVM检测到一段代码中的锁是多余的,也就是说,这段代码访问的数据只能被一个线程访问,其他线程无法访问,那么JVM就会把这个多余的锁给消除掉。

这种情况常见于对象的作用范围仅限于同步块内的场景,即在调用monitorenter/monitorexit指令时,如果发现这两个指令的对象锁的范围仅仅局限于某一个线程,那么这个对象锁就可以被“消除”,也就是说,这两个指令可以被无效化。

简单来说,如果一段代码被synchronized修饰,但其实在运行期间,那个被synchronized修饰的代码块没有竞态条件,那么虚拟机会取消这个锁,这就叫做锁消除

# 4.4、锁粗化

锁粗化是将多个连续的对同一对象加锁和解锁的操作合并为对该对象一次连续的加锁和解锁操作。

们通常的做法是把锁的粒度做得尽可能小,原因是这样可以使需要阻塞等待的线程尽可能少,以提升并发效率。但是,如果一段代码内有对同一个对象多次加锁和解锁的操作,且中间并无对锁释放后,再次申请的必要性,要频繁地进行加锁解锁操作,其开销也是相当高的。

在这种情况下,JVM就会采取锁粗化的策略:将多个连续的对同一对象加锁和解锁的操作合并为对该对象一次连续的加锁和解锁操作。也就是说,把在一个线程中,连续对同一个锁反复加锁和解锁的行为合并为一次性对锁的请求和释放。

这样,就可以减少线程请求和释放锁的次数,从而达到提高效率的目的。

# 4.5、偏向锁

偏向锁消除了无竞争同步的情况下的部分无关操作,假设一个对象在运行过程中主要被一个线程所访问,则使这个线程持有该对象锁的代价更低,以提高程序的整体性能。

  • 偏向锁的思想是,当一个线程访问一个被锁保护的对象时,JVM会偏向于这个线程,认为该线程在未来的一段时间内将会独占该对象,从而省去了后续获取锁的操作。当其他线程也想要获取该对象的锁时,JVM会先检查该对象是否处于偏向状态,如果是,则检查当前持有锁的线程是否为自己,如果是,则直接执行代码;如果不是,则通过撤销偏向锁并重新竞争锁。这个过程称为锁重获。
  • 偏向锁的优势在于避免了多余的锁竞争,减少了对系统的影响,提高了程序的执行效率。但是偏向锁也有一些限制,比如在竞争激烈的场景下,偏向锁的撤销和重获操作可能会成为性能瓶颈,此时最好使用其他的锁策略。

偏向锁的撤销

偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。

# 4.6、轻量级锁

轻量级锁是一种JVM的锁实现技术,用于提高多线程竞争下的锁性能。它的设计目标是在锁竞争不激烈的情况下,通过减少重量级锁的使用,减少锁操作的开销。

  • 轻量级锁的思想是在对象的头部添加一个指向锁记录的指针,该指针指向一个锁记录的数据结构。当一个线程尝试获取轻量级锁时,首先会将对象的锁记录拷贝到自己的线程栈中,并将对象头部中的锁记录指针指向线程栈中的锁记录。接着,线程会使用CAS操作将对象的锁记录指针修改为指向自己的锁记录,如果CAS成功,线程就获得了轻量级锁。在获取到锁的过程中,其他线程还是可以继续读取该对象的锁记录,并根据线程栈中的锁记录信息判断是否有其他线程拥有该对象的锁。
  • 当一个线程释放轻量级锁时,它会使用CAS操作判断自己是否还持有锁记录,如果是的话,会将对象头部的锁记录指针指向它所持有的锁记录,同时撤销自己的锁记录。如果判断失败,说明其他线程已经获取到了锁,需要使用适当的解锁操作进行处理。
  • 轻量级锁的优势在于避免了线程阻塞和唤醒的开销,减少了系统调用的开销,在锁竞争不激烈的情况下,性能较好。但是当锁竞争激烈时,会引发许多锁膨胀的操作,可能会影响性能。

轻量级锁加锁

  • 当代码进入到同步块之后,如果此时同步对象没有被锁定,虚拟机会在当前线程的栈帧中建立一个名为“锁记录(Lock Record)”的空间,用来存储该锁对象目前的mark word的拷贝。
  • 然后,虚拟机会使用CAS操作试图把对象头的mark word更新为指向锁记录的指针。如果更新成功,那么这个线程就获得了该对象的锁,并把锁标志位设为“01”,表示当前锁是轻量级锁。
  • 如果CAS操作失败,表示其他线程试图获取同一个锁,那么当前线程就尝试使用自旋锁来等待其他线程释放锁。
  • 如果自旋锁之后,锁仍然没有被释放,那么轻量级锁就会膨胀为重量级锁,此时锁标志为"10",线程会进入阻塞状态。

# 4.7、重量级锁

排他锁也就是重量级锁,synchronized没有优化之前就是重量级锁,多线程执行过程中,synchronized加锁时一定会造成阻塞。

# 4.8、锁的各等级对比

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 如线程始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步块执行速度较长

# 5、synchronized与Lock对比

# 5.1、synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁:相对而言,Lock可以拿到状态。

# 5.2、Lock对应解决方案

  • lock(): 加锁
  • unlock(): 解锁
  • tryLock(): 尝试获取锁,返回一个boolean值
  • tryLock(long,TimeUtil): 尝试获取锁,可以设置超时

ReentrantLock 是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。