synchronized 的使用

为了充分利用 cpu 资源,我们经常会使用多线程来合理的利用 cpu 空闲资源。但是在使用多线程的时候,由于多个线程存在对内存中对象的拷贝,当多个线程对一个资源进行访问的时候,会出现线程不安全的情况。为了避免这种线程不安全的情况 ,jdk 提供了 synchronied 的方式来保证同步代码块的安全问题。

synchronid 的加锁有 2 种类型,分别为对象锁和类锁。

  • 对象锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public synchronized   void  method1(){
    while (true){
    try {
    TimeUnit.SECONDS.sleep(1);
    System.out.println("当前线程:"+Thread.currentThread().getName()+" 开始工作。");
    } catch (InterruptedException e) {
    e.printStackTrace();
    }

    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public  void  method2(){
    synchronized (this){
    while (true){
    try {
    TimeUnit.SECONDS.sleep(1);
    System.out.println("当前线程:"+Thread.currentThread().getName()+" 开始工作。");
    } catch (InterruptedException e) {
    e.printStackTrace();
    }

    }
    }

    }

    上面演示了 2 种对象锁,一种是在方法上面直接加 synchronied 修饰,一种是在代码块种增加 synchronized(this)修饰,这 2 种的效果是一样的,锁对象都是当前类的实例对象。一般采用第二种,避免锁住不需要同步的代码。

  • 类锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public static  synchronized  void method3(){
    while (true){
    try {
    TimeUnit.SECONDS.sleep(1);
    System.out.println("当前线程:"+Thread.currentThread().getName()+" 开始工作。");
    } catch (InterruptedException e) {
    e.printStackTrace();
    }

    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public  void method4(){
    synchronized(SyncDemo.class){
    while (true){
    try {
    TimeUnit.SECONDS.sleep(1);
    System.out.println("当前线程:"+Thread.currentThread().getName()+" 开始工作。");
    } catch (InterruptedException e) {
    e.printStackTrace();
    }

    }
    }

    }

    上面也演示了两种类锁的形式,一种是在静态方法上面增加 synchronied,一种是在代码块种增加 synchronied(类名.class)。2 种类锁的实现效果一致,一般采用第二种,避免锁住不需要同步的代码。

    那么对象锁和类锁的区别在那呢,我们通过实践来验证。

  • demo1

    1
    2
    3
    4
    5
    6
    7
    public static void main(String[] args) {
    SyncDemo syncDemo = new SyncDemo();

    new Thread(() -> syncDemo.method1(),"t1").start();
    new Thread(() -> syncDemo.method1(),"t2").start();

    }

    image-20210311195655238

    当我们使用对象锁的时候,2 个线程同时调用一个对象的方法的时候,后面执行的线程会处于阻塞状态。

  • demo2

    1
    2
    3
    4
    5
    6
    7
    8
    public static void main(String[] args) {
    SyncDemo syncDemo = new SyncDemo();
    SyncDemo syncDemo2 = new SyncDemo();

    new Thread(() -> syncDemo.method1(),"t1").start();
    new Thread(() -> syncDemo2.method1(),"t2").start();

    }

    我们创建 2 个对象实例,通过 2 个线程去去执行不同实例的同步方法,运行结果如下:

    image-20210311195822617

    可以看出,2 个线程互不干扰,没有阻塞操作。

    通过 demo1 和 demo2 可以证明

    当我们是使用对象锁的时候,锁住的是当前的对象,如果有多个对象实例,是不会有任何同步关系的。

  • demo3

    1
    2
    3
    4
    5
    6
    public static void main(String[] args) {
    SyncDemo syncDemo = new SyncDemo();
    new Thread(() -> SyncDemo.method3(),"t1").start();
    new Thread(() -> syncDemo.method4(),"t2").start();

    }

    执行结果:

    image-20210311195915466

    建立 2 个线程分别执行加了同一个类锁的方法,第一个线程会阻塞第 2 个线程。

    从 demo3 可以得出结论

    加了类锁的方法,如果存在多个线程同时访问的情况,无论是通过类调用,还是对象调用,都会阻塞其他线程

synchronized 的实现

我们既然知道保证线程安全的方法是通过加锁的来实现,那么锁加在哪里呢,就保存在锁对象信息里面。

在 java 虚拟机中,每个对象中都存在 3 个部分的数据,分别为对象头,实例数据,对齐填充。其中对象头中就保存了对象锁的基本信息。

image-20210311200100632

对象头的信息,也就是 MarkWord。

MarkWord 根据操作系统的不同,存储的信息也不同,以下图 32 位操作系统为例:

image-20210311200131432

MarkWord 存储的信息随着锁标记位变化而变化。那这些锁标记是如何变化的,我们继续往下分析

synchronized 锁的升级

我们知道加了 synchronied 之后, 代码就能实现线程安全 ,但是 synchronied 的最初的实现是当一个线程占用了对象锁之后,其他线程直接进行阻塞,而线程从运行状态到阻塞状态再到运行状态,是会浪费很多资源的,所以在 jdk6 之后, jvm 对 synchronied 做了改良操作,也就是锁的升级操作。

  1. 无锁:还没有被线程调用到同步代码块的时候

  2. 偏向锁:只有 1 个线程 ThreadA 访问,虚拟机通过 CAS 操作,从对象锁中获取锁标示,如果直接能够获取到,那么就处于当前状态。这个时候对象的头的信息修改成上图偏向锁的内容,保存了当前线程 id

  3. 轻量级锁:当 ThreadA 还没有执行结束,这个时候 ThreadB 执行到了同步代码块,发现对象锁中已经偏向,并且保存的线程信息不是当前线程。这个时候,会暂停线程 ThreaA,并将对象有的信息修改成 轻量级锁的内容。而 ThreadB 在这个时候,会进行自旋操作 ,不断的循环调用 CAS 操作。如果在这个自旋期间 ThreadA 执行结束,那么 ThreadB 会立马直接执行,如果自旋多次之后还是无法获取到锁标示,那么就锁升级生重量级锁。(自旋操作可以理解成多次重试补救操作)

  4. 重量级锁:重量级锁就是我们平常说的锁了,直接阻塞其他线程。

20190928204105161

20190928204152854

从锁的膨胀过程来看,我们可以得出其实偏向锁和轻量级锁的实现都不是真正加锁阻塞实现的。偏向锁是通过 CAS 实现的,轻量级锁是通过自旋操作实现的。

那么重量级锁是怎么实现的呢,重量级锁是通过对象监视器 monitor 来实现的。每个对象都会于一个监视器 monitor 关联名,我们可以理解成一把锁。

我们可以通过 javap -v 类的路径 指令来查看同步代码块的编译指令,对于加了同步代码块的代码中会增加 monitorenter 和 monitorexit 指令

image-20210311200559905

monitorenter 表示去获取一个对象监视器,monitorexit 表示去释放 monitor 监视器的所有权。

monitor 监视器的底层结构如下:

image-20210311200624078

其中 count 为重入次数,owner 为持有的线程对象,waitSet 为阻塞队列,entrySet 为等待队列。

重量级锁的获取和释放流程如下图所示

image-20210311200645120