张三竟然当众扒掉了synchonized的裤子?

636 阅读10分钟

张三竟然当众扒掉了synchonized的裤子?

在多线程环境下,多个线程对同一个资源进行争抢可以会导致数据不一致的问题,很多编程语言都引入了锁的概念,今天我们就来扒一扒Java中synchonized关键字的裤子。

synchonized简介

synchonized(下文简写为Sync)是java中的一个重要关键字,用来实现对象的锁定。大家可以把需要操作的对象想象成一个只能容纳一个人的单人浴室,线程就相当于想要进入这个浴室的人,而锁就是这个浴室的门锁,当一个线程(人)获得了这个对象(浴室)的使用权,他就可以锁定对象(锁上浴室门)并操作对象(使用浴室),其他线程(其他人)只能阻塞等待(在门口排队)。如果没有锁,那么在你使用浴室的时候有其他人进入浴室,产生一些不可描述的现像。

synchonized的实现

synchonized对共享资源的锁定,主要是通过Monitor(监视器)来实现的,这个Monitor是通过C++来实现的

图片来源www.yuque.com/leienaction…

我们来对上图进行分析,当有多个线程需要对某个资源进行锁定时,需要先通过①进入EntryList,在EntryList中的线程通过②acquire操作获取该monitor,一旦获取成功,其他线程需要在EntryList中阻塞等待,获取成功的线程可以对对象进行修改,此时如果有当前线程调用wait方法,则会暂时让出执行权,通过③进入waitSet中等待重新获取执行权,一旦获取成功则重新进入并继续执行,当操作结束后通过⑤交出执行权并离开monitor,其他线程再进行争抢Monitor

我们一起来看一下Monitor的源码

  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;     // 线程重入次数
    _object       = NULL;  // 存储Monitor对象
    _owner        = NULL;  // 持有当前线程的owner
    _WaitSet      = NULL;  // wait状态的线程列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

代码来源mp.weixin.qq.com/s/2ka1cDTRy…

我们来分析一下上面这段代码的一些字段

_recursions = 0; 这个字段指的是当前线程的可重入次数,也就是当前锁未释放的情况下,获得该锁的线程可以在不释放锁的情况下重新获取锁,并将该字段自动加一,每次该线程释放锁的时候会将该字段减一,其他线程在该字段为0时才可以对其进行争抢,如果不是0则阻塞等待。

_object = NULL; 这个字段记录了当前Monitor对哪个对象进行监视

_owner = NULL; 这个字段记录了当前Monitor被哪个线程所持有

_WaitSet = NULL; 记录了当前WaitSet中的线程

_EntryList = NULL ; 记录了EntryList中阻塞等待的线程

Monitor怎么与对象进行绑定的呢,我们一起去字节码里面找找答案

public class Test {
    public void test1(){
        synchronized (this) {
        }
    }
    public synchronized void test2(){
    }
    public synchronized static void test3(){
    }
}

我们对他执行javap -v Test.class 看一下反编译的结果

test1() 我们可以发现,sync代码块在反编译中被加入了monitorenter和monitorexit两条指令,执行monitorenter的时候会在前面提到的Monitor源码中_recursions字段加一,且 _owner字段修改为当前线程,当执行monitorexit指令时 _recursions字段减一

test2() 当我们在方法上加Sync修饰时,会在方法的访问修饰符上添加ACC_SYNCHONIZED,它会隐式的调用monitorenter和monitorexit两条指令

synchonized作用对象

synchonized关键字修饰范围分为两类,修饰方法和修饰代码块

Sync修饰方法

当sync修饰类方法(static方法)时,会锁定当前方法所属类的Class对象,假设当前类中存在两个Sync类方法,两个线程分别调用这两个方法,此时Sync会锁定当前Class对象,由于Class对象全局唯一,所以两个线程会按抢占锁的先后顺序依次执行。

当Sync修饰实例方法时,会锁定当前方法所属对象,假设当前类中存在两个实例方法,两个线程分别调用同一对象的两个Sync方法,此时Sync会锁定当前实例对象,所以两个线程会按抢占锁的先后顺序依次执行。但是当两个线程分别调用同一类创建的不同对象的两个Sync方法,此时Sync会分别锁定两个实例对象,二者互不干扰。

Sync修饰代码块

当Sync修饰代码块时,需要在synchonized后面带入所需锁定的对象,此时会锁定该对象,没有抢到锁的线程会阻塞等待

无聊的八锁问题

八锁问题的本质是确定不同线程锁定的对象,只要搞清楚上文中Sync修饰不同方法时锁定的对象就可以了

1.两个线程分别调用一个类下的两个Sync类方法

class Phone{
    public synchronized static void sendEmail(){
        System.out.println("senEmail");
    }
    public synchronized static void callPhone(){
        System.out.println("callPhone");
    }
}

由于sync修饰类方法会锁定Class对象,而Class对象全局唯一,所以先抢到锁的线程会先调用

2.两个线程分别调用一个类下的两个Sync类方法,其中sendEmail方法会sleep三秒

class Phone{
    public synchronized static void sendEmail(){
        Thread.sleep(3000);
        System.out.println("senEmail");
    }
    public synchronized static void callPhone(){
        System.out.println("callPhone");
    }
}

同上,由于Sync修饰类方法会锁定Class对象,而Class对象全局唯一,sleep()不会释放当前锁,所以先抢到锁的线程会先调用

3.两个线程分别调用同一对象的两个Sync实例方法

class Phone{
    public synchronized void sendEmail(){
        System.out.println("senEmail");
    }
    public synchronized void callPhone(){
        System.out.println("callPhone");
    }
}

由于两个线程调用同一对象,此时先抢到锁的线程会锁定该对象,其他线程阻塞等待直到锁被释放

4.两个线程分别调用同一对象的两个Sync实例方法,其中sendEmail方法会sleep三秒

class Phone{
    public synchronized void sendEmail(){
        Thread.sleep(3000);
        System.out.println("senEmail");
    }
    public synchronized void callPhone(){
        System.out.println("callPhone");
    }
}

同上,由于两个线程调用同一对象,此时先抢到锁的线程会锁定该对象,sleep方法不会释放当前锁,其他线程阻塞等待直到锁被释放

5.类中存在一个Sync类方法,一个Sync实例方法

class Phone{
    public synchronized static void sendEmail(){
        Thread.sleep(3000);
        System.out.println("senEmail");
    }
    public synchronized void callPhone(){
        System.out.println("callPhone");
    }
}

由于两个Sync会分别锁定不同对象,所以二者互不影响,按线程调度顺序执行。

两条线程分别调用同一对象的两个方法,其中一个被Sync修饰

class Phone{
    public synchronized void sendEmail(){
        Thread.sleep(3000);
        System.out.println("senEmail");
    }
    public void callPhone(){
        System.out.println("callPhone");
    }
}

由于callPhone()方法不被Sync修饰,不需要竞争锁,所以两条线程各自调用不受影响。

7.两条线程分别调用Sync类方法和实例对象的普通方法

class Phone{
    public synchronized static void sendEmail(){
        Thread.sleep(3000);
        System.out.println("senEmail");
    }
    public void callPhone(){
        System.out.println("callPhone");
    }
}

由于callPhone()方法不被Sync修饰,不需要竞争锁,所以两条线程各自调用不受影响。

两条线程分别调用两个对象的sync方法(线程1调用对象a的sendEmail方法,线程2调用对象b的callPhone方法)

class Phone{
    public synchronized void sendEmail(){
        Thread.sleep(3000);
        System.out.println("senEmail");
    }
    public synchronized void callPhone(){
        System.out.println("callPhone");
    }
}

由于Sync会锁住当前实例对象,而两条线程调用的对象不同,所以二者不会发生争抢,各自按顺序执行。

JDK1.6对synchonized的优化

在JDK1.6中对synchonized进行了大量的优化,将加锁过程分为无锁、偏向锁、轻量级锁、重量级锁,锁定程度依次递增,在谈这四种锁升级过程前,我们需要先了解一下java对象的对象头

Java对象的对象头一般由两部分组成,分别是存储对象自身运行时数据的MarkWord,以及指向方法区中对象类型数据的指针,如果对象是数组类型,还需要一些额外部分存储数组长度,这里我们重点需要关注MarkWord

无锁

在无其他线程竞争的情况下,对象头里包含对象hashCode信息、分代年龄、是否是偏向锁、锁标志位。

偏向锁

当有一条线程来操作该对象时,会将对象头前23bit修改为当前线程的ID并将倒数第三个bit位置为1表示进入偏向锁状态。在该状态下,被记录ID的线程可以直接对该对象进行操作。

轻量级锁

在偏向锁状态下,如果有其他线程来竞争资源,会将偏向锁升级为轻量级锁,在此过程中,将锁标志位置为00,在线程栈中开辟一块名为Lock Record的区域,并通过CAS操作将MarkWord的拷贝记录在Lock Record中,MarkWord中的信息会被修改为指向当前线程LockRecord地址的指针,同时Lock Record中会产生一个指向当前对象的Owner指针,从而实现二者的绑定。其他想要获取锁的线程将自旋等待,如果自旋次数超过十次或自旋线程个数超过CPU核数一般,则会升级为重量级锁

重量级锁

在升级为重量级锁之后,会将所标志位置为10,然后会像文章开头所述通过Monitor对对象进行监控。

关于synchonized的一些碎碎念

在JDK1.6中还引入了锁消除和锁粗化的概念,我们一起来看一下吧

锁消除

我们知道StringBuffer是一个线程安全的类,里面的方法大量使用Sync修饰,我们已下面这段代码为例子说一下锁消除。

public class Test {
    public String test1(){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("关注");
        stringBuffer.append("点赞");
        stringBuffer.append("在看");
        return stringBuffer.toString();
    }
}

StringBuffer中append方法源码

    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

我们可以看到append()方法使用Sync修饰,但是如果在单线程环境下操作我们的测试代码,其实并不会有线程安全的问题,但是还是会有反复加锁解锁的过程。经过逃逸分析,JVM会发现stringBuffer这个对象的动态作用域被限定在test1()中,其他线程无法访问,因此即使append方法带有Sync修饰,也会安全的将锁消除,避免了反复加锁解锁的过程

锁粗化

我们还以StringBuffer的append方法来说明

public class Test {
    public String test1(){
        StringBuffer stringBuffer = new StringBuffer();
        for(int i = 0 ; i < 10 ; i ++){
            stringBuffer.append("点个关注吧");
        }
        return stringBuffer.toString();
    }
}

在这段代码中append方法被调用十次,也就是会出现十次加锁解锁过程,频繁的加锁解锁会造成不必要的性能浪费。在JVM探查到一连串同步操作都作用于同一个对象的时候会直接对将加锁范围扩大到整个操作序列(以上面测试代码为例,会消去append中的锁,而将锁加到整个for循环中,从而减少加锁次数)

欢迎大家关注我的微信公众号 "码外狂徒"

参考链接 死磕Synchronized底层实现

mp.weixin.qq.com/s/2ka1cDTRy…

Java基础面试16问

mp.weixin.qq.com/s/-xFSHf7Gz…

《深入理解Java虚拟机》

【多线程】月薪20K必须知道的Java锁机制

www.bilibili.com/video/BV1xT…

synchronized关键字&实现原理&锁升级(原) www.yuque.com/leienaction…