一层一层揭开synchronized的神秘面纱

前言

在上一篇讲解CAS的文章里提到synchronized加锁开销很大,那么为什么会开销大呢?这篇文章主要讲解的内容是synchronized的底层原理以及锁升级的过程。

并发编程的三大问题

首先我们要知道为什么要使用synchronized加锁,那是因为在并发编程中会出现原子性问题可见性问题有序性问题,导致结果不是我们希望的,所以需要进行同步操作,而使用synchronized加锁是一种保证同步性的方法。下面我们来讲解一下并发编程的这三大问题。

原子性问题

原子性问题指的是在一次或者多次操作中,要么所有操作都执行,要么所有操作都不执行。通过下面的例子你可以发现原子性问题。

public class Demo {

    static int count;//记录用户访问次数

    public static void request() throws InterruptedException {
        //模拟请求耗时5毫秒
        TimeUnit.MILLISECONDS.sleep(5);
        count++;
    }
    
    public static void main(String[] args) throws InterruptedException {
        //开始时间
        long startTime = System.currentTimeMillis();
        int threadSize = 100;
        //CountDownLatch类就是要保证完成100个用户请求之后再执行后续的代码
        CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        for (int i = 0; i < threadSize; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    //模拟用户行为,访问10次网站
                    try{
                        for (int j = 0; j < 10; j++)
                            request();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        countDownLatch.countDown();
                    }
                }
            });
            thread.start();
        }
        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        System.out.println(Thread.currentThread().getName()+"耗时:"+(endTime-startTime)+",count="+count);
    }
}

同时又100个线程,每个线程执行了10次request()方法,对count变量自增,结果却不是1000,这是因为count++并不是一个原子性操作,通过反编译可以知道count++包含了四条字节码指令,所以多个线程同时操作的时候,线程执行count++会收到其他线程的干扰。
在这里插入图片描述

可见性问题

可见性问题指的是一个线程在访问一个共享变量的时候,其他线程对该共享变量的修改对于第一个线程来说是不可见的,下面通过一个例子可以发现可见性问题。

public class Visable {
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {

        new Thread(()-> {
            while(flag) {

            }
        }).start();

        Thread.sleep(2000);

        new Thread(() -> {
            flag = false;
            System.out.println("修改了共享变量flag的值");
        }).start();
    }
}

在这份代码中,声明了一个共享变量flag,然后声明了一个线程一直在读这个共享变量,另一个线程修改了共享变量,我们运行发现,当另一个线程修改了共享变量之后,第一个线程仍然在循环运行,所以这就是并发编程中的可见性问题。

有序性问题

有序性问题指的就是JVM在编译器和运行期会对执行进行一个重排序,导致最终程序代码运行的顺序与开发者一开始编写的顺序不一致,导致出现有序性问题。


synchronized的使用

synchronized可以修饰方法,也可以修饰代码块。

修饰方法

静态方法

public class Demo{
	public static synchronized void request(){
		.....
	}
}

synchronized修饰静态方法的时候其实是锁定了Demo的类对象。

非静态方法

public class Demo{
	public synchronized void request(){
		.....
	}
}

synchronized修饰非静态方法的时候其实是锁定了Demo类的实例对象(this)。

修饰代码块

public class Demo{
	private Object o = new Object();
	public static void request(){
		synchronized(o) {
			...
		}
	}
	public static void request1(){
		synchronized(this) {
			...
		}
	}
}

synchronized(o)锁定的其实是o对象,而synchronized(this)锁定的其实是this对象。


synchronized的特性

synchronized主要有两大特性,分别是不可中断特性可重入特性

不可中断特性

不可中断特性指的是,当线程在竞争共享资源的时候,如果资源已经上锁了,那么线程会阻塞等待,直到锁释放,这个阻塞等待过程是不能被中断的。通过下面的例子可以证明synchronized的不可中断特性。

public class UnBlocked {

    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                synchronized (this) {
                    try {
                        TimeUnit.MILLISECONDS.sleep(8888);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        Thread.sleep(1000);
        t2.start();

        System.out.println("t2中断前");
        t2.interrupt();
        System.out.println("t2中断后");
        System.out.println("t1线程的状态:"+t1.getState());
        System.out.println("t2线程的状态:"+t2.getState());
    }
}

在这里插入图片描述
这段代码中声明了两个线程,线程t1一直占用着锁对象,然后线程t2一直处于阻塞状态,调用中断函数也不可中断线程t2的阻塞状态,所以这就是synchronized的不可中断性

Q:synchronized与Lock的区别

提到synchronized的不可中断特性,不得不提到Lock,对于Lock来说默认是不可中断的。但是可以调用Lock对象的trylock()方法,可以在线程阻塞等待一段时间之后,自动中断阻塞等待状态,这也是synchronized与Lock的一个区别。其次还有Lock可以返回锁的状态,而synchronized是一个无状态锁,你是不知道线程的锁定状态的。

可重入性

synchronized的可重入性指的是同一个线程可以多次获取同一把锁,synchronized的锁对象关联的monitor对象中会有一个可重入计数器,当同一个线程访问该锁对象,可重入计数器会加一,然后释放锁的时候,可重入计数器会减一。


synchronized的底层原理

synchronized的底层原理与Java对象头Monitor对象息息相关,所以先了解一下Java对象头与Monitor对象的结构,然后再分别讲解一下synchronized修饰方法和代码块的底层原理。

Java对象头

Java对象在JVM内存结构的布局分为三部分:对象头实例数据对齐填充

结构说明
对象头对象头又分为Mark Word类型指针数组长度(可选)
实例数据类中的属性数据信息,包括父类的属性数据信息
对齐填充JVM要求对象的起始地址是8的倍数,如果不满足,使用对齐填充

关于synchronized的锁信息是存放在对象头中的Mark Word中,所以重点讲解一下对象头,对象头的结构如下:

对象头结构说明
Mark Word对象的运行时数据:hashcode、分代年龄、锁信息以及GC标记等信息
类型指针通过类型指针可以知道该对象属于哪个类
数组长度(可选)如果是数组对象,就代表数组的长度

其中与synchronized锁对象相关的信息是在Mark Word(运行时数据)中的,那我们来看看Mark Word的结构如下:
在这里插入图片描述
可以看到在JDK1.6之前,还没有锁升级的概念,synchronized是重量级锁,锁标识是10,Mark Word中存放了指向重量级锁对象monitor的指针

Monitor对象

每一个对象都会有一个关联的Monitor对象,synchronized的锁对象实际上是Monitor对象,所以synchronized可以锁任何对象,Monitor对象对应的底层数据结构是ObjectMonitor

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

其中几个重要的属性是:

属性名说明
_recursions代表一个线程的重入次数
_owner指向获取到Monitor对象的线程
_WaitSet处于wait状态的线程,会被加入到_WaitSet
_EntryList处于等待锁block状态的线程,会被加入到_EntryList
_object指向关联的锁对象

当多个线程访问同一个同步代码的时候,会先进入_EntryList区域,获取ObjectMonitor对象的线程会进入_Owner区域,并且将ObjectMonitor对象的_owner指向当前线程,计数器_count自增1,若线程调用wait()方法,线程会释放持有的monitor,owner变量恢复为Null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor对象并复位变量的值,以便其他线程可以进入获取monitor锁。如下图所示:
在这里插入图片描述

synchronized代码块底层原理

通过反汇编的方式了解synchronized代码块的底层原理
首先我们反编译用synchronized修饰的代码块,看看被synchronized修饰的代码块的字节码指令有什么不同?

public class Demo1 {

    private static Object object = new Object();

    private static int count = 0;

    public static void main(String[] args) {
        synchronized (object) {
            count++;
        }
    }
}

在这里插入图片描述
我们发现在count++的四个字节码指令外面包裹了一个monitorentermonitorexit字节码指令。

monitorenter指令

当JVM执行到某个线程中某个方法的monitorenter字节码指令的时候,会尝试去获取当前对象的monitor对象的所有权,具体过程如下:

  1. 若monitor的进入计数器为0,则该线程可以获得monitor的所有权,将monitor的计数器置为1,当前线程成为monitor的拥有者(owner);
  2. 若该线程已经拥有了monitor的所有权,可以直接重入,然后monitor的重入计数器自增1;
  3. 若monitor对象已经被其他线程拥有了,当前尝试获取monitor对象的线程会被阻塞,直到monitor的进入数为0时,才能重新尝试获取monitor对象。

monitorexit指令

monitorexit指令的执行过程如下:

  1. 首先执行monitorexit指令的线程一定是拥有了monitor对象的所有权的。
  2. 执行monitorexit指令会将monitor对象的可重入数减一,如果可重入数为0,当前线程就会释放monitor对象,不再拥有monitor对象的所有权。此时其他阻塞等待获取这个monitor对象的线程就会被唤醒,重新尝试获取monitor对象。

Q:为什么会有两个monitorexit指令?

这是因为当执行同步块代码过程中发生异常了,会自动释放锁,所以第二个monitorexit指令是用于异常释放锁的。

synchronized方法底层原理

public synchronized void test(){
        System.out.println("synchronized修饰方法");
}

通过反编译可以看到被synchronized修饰的方法test()的字节码指令如下:
在这里插入图片描述
可以看到被synchronized修饰的方法并没有monitorentermonitorexit字节码指令,取而代之的是红色框里面的ACC_SYNCHRONIZED标识,标识了这是一个同步方法。当方法被调用时,会先检测同步标识ACC_SYNCHRONIZED是否被设置了,如果设置了,则执行线程先获取monitor对象,然后再执行方法,最后方法完成时会释放monitor对象。在方法执行期间,执行线程持有了monitor对象,其他线程是无法获取同一个monitor对象的。

Q:为什么说在JDK1.6之前synchronized是重量级锁?

JDK1.6访问synchronized修饰的代码块或者方法,都会加锁,首先加锁的过程就是获取monitor对象的过程,涉及到一些系统调用,所以会有内核态和用户态切换,就会消耗资源。其次,线程的阻塞和唤醒过程涉及到了线程上下文切换,也是涉及了内核态和用户态的切换,所以整体来说JDK1.6之前synchronized是一个重量级锁。


synchronized的锁升级过程

synchronized在JDK1.6之后进行了一个优化,根据并发量的不同,经历了无锁->偏向锁->轻量级锁->自旋锁->重量级锁这么一个锁升级的过程。

偏向锁

偏向锁,顾名思义就是偏向第一个获取到锁对象的线程,并且在运行过程中,只有一个线程会访问同步代码块,不会存在多线程竞争,这种情况下加的就是偏向锁

偏向锁的获取过程

在这里插入图片描述

  1. 判断对象的MarkWord。
  2. 判断MarkWord中是否开启偏向锁模式。
  3. 如果为可偏向状态,判断MarkWord中的ThreadID是否为空,如果为空,则通过CAS操作设置成当前线程的线程ID,然后执行步骤5。否则执行步骤4。
  4. 如果不为空,则判断是否是当前线程ID,如果是则直接执行同步代码块,如果不是,则存在锁竞争,等到全局安全点的时候撤销偏向锁,将锁标记设置为无锁或者轻量级锁状态。
  5. 执行同步代码块。

偏向锁撤销

  1. 偏向锁的撤销必须等到全局安全点,指的是没有字节码执行执行的时刻。
  2. 暂停持有偏向锁的线程,判断锁对象是否处于被锁状态。
  3. 撤销偏向锁,恢复到无锁或者升级到轻量级锁。

偏向锁撤销的过程中,在全局安全点的时候,会STW(STOP THE WORLD),如果确定应用程序中存在锁竞争,可以通过设置参数-XX:-UseBiasedLocking=false来关闭偏向锁模式,减少额外的开销


轻量级锁

在偏向锁状态下,当有另一个线程尝试进入同步代码块的时候,就会存在锁竞争,此时偏向锁就会升级为轻量级锁。

轻量级锁的获取过程

在这里插入图片描述

  1. 获取MarkWord对象。如果同步对象锁的状态为无锁状态,首先在当前线程的栈帧中创建一个锁记录(Lock Record)。这时候栈帧和MarkWord的状态如图:
    在这里插入图片描述
  2. 拷贝对象头的MarkWord复制到锁记录中的displaced hdr字段。
  3. 拷贝成功之后通过CAS操作让MarkWord更新为指向Lock Record的指针,并将Lock Record中的owner指针指向MarkWord对象。如果更新成功则执行步骤4,否则执行步骤5。
  4. 如果更新成功,则线程拥有了锁对象,执行同步代码块
  5. 如果更新不成功,判断MarkWord中是否指向当前栈帧中的Lock Record,如果是则直接执行同步代码块,如果不是,则存在锁同时竞争,轻量级锁就要升级为重量级锁。

自旋锁

如果存在多个线程同时竞争轻量级锁的时候,会膨胀升级为重量级锁,但是对于当前线程来说,会尝试自旋来获取锁,而不会立刻就阻塞等待,自旋锁其实就是采用循环去获取锁的一个过程。
可以通过参数-XX:+UseSpinning来开启自旋锁。如果一直自旋的话,就会消耗完CPU资源,所以一般是自旋超过一定次数就会退出自旋模式,而进入重量级锁。可以通过设置参数-XX:PreBlockSpin来更改自旋默认次数。


自适应自旋

在JDK1.6中对自旋锁进行了优化,如果一个在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么就会延长自旋的次数。如果对于某个锁,自旋很少成功获得过锁,则会直接忽略掉自旋过程,避免浪费CPU资源。


重量级锁

重量级锁指的就是获取monitor对象的过程,需要调用操作系统的底层函数,所以涉及到内核态和用户态的切换,切换成本非常高。


使用场景

偏向锁:适用于没有线程竞争资源的场景。
轻量级锁:适用于多个线程不同时刻竞争资源的场景。
重量级锁:适用于多个线程同时竞争资源的场景。


小结

所以synchronized的锁升级过程为

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

锁优化

上面介绍了synchronized在JDK1.6之后的优化过程,进行了一个锁升级过程。那么我们在写多线程代码的时候也可以借鉴synchronized锁优化中的一些思想来优化我们的代码

减少锁的时间

不需要同步执行的代码,不要放到同步代码块中,可以让锁尽快释放。

减少锁的粒度

减少锁的粒度的思想就是将一把锁拆分成多把锁,增加了并发度,减少了锁竞争,ConcurrentHashMap在JDK1.8之前就是采用了”分段锁“的思想,还有LongAddr也是采用了”分段锁“的思想,降低了锁的粒度,提高了并发度。

锁粗化

如果同步代码块中包含循环,应该将锁放在循环以外,不然循环每次都会进入共享资源,效率会降低。

锁消除

锁消除其实就是对于同步代码块执行时间不长,并且并发量不大的情况下,可以采用CAS等乐观锁来进行同步操作,减少了加锁释放锁带来的开销。

读写分离

读写分离指的是,写的时候可以复制一份数据,然后写操作可以加锁,读操作就读原来的数据,比如CopyOnWriteArrayList集合类采用的就是读写分离的方式来解决并发安全的问题。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值