查看原文
其他

面试官最想要的synchronized,你值得拥有

黎杜 非科班的科班 2020-09-07

synchronized简介

synchronizedJava语言的一个关键字,它本身的意思为同步,是用来保证线程安全的,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。

synchronized一句话来解释其作用就是:能够保证同一时刻最多只有一个线程执行该段代码,以达到并发安全的效果synchronized就犹如一把锁,当一个线程获取到该锁,别的线程只能等待其执行完才能执行。

synchronized可以说是Java中元老级的关键字了,也是面试的高频的问点,在jdk1.6之前它是一把重量级锁,性能不被大家看好,在次之后对它做了很多优化,性能也大大提升。

那么synchronized的实现的底层原理是什么,jdk1.6之后又对它做了哪些优化呢?接下来我们一步一步的分析。

synchronized的特性

synchronized能够保证在多线程的情况下线程安全,直接可以它的特性进行总结原因,synchronized有以下四个特性

  1. 原子性:保证被synchronized修饰的一个或者多个操作,在执行的过程中不会被任何的因素打断,即所谓的原子操作,直到锁被释放。
  2. 可见性:保证持有锁的当前线程在释放锁之前,对共享变量的修改会刷新到主存中,并对其它线程可见。
  3. 有序性:保证多线程时刻中只有一个线程执行,线程执行的顺序都是有序的。
  4. 可重入性:保证在多线程中,有其他的线程试图竞争持有锁的临界资源时,其它的线程会处于等待状态,而当前持有锁的线程可以重复的申请自己持有锁的临界资源。

上面的也是粗略的进行概括,接下来就一步一步的进行深入的分析synchronized的这四个特性的底层原理。

原子性

上面介绍了原子性就是一个或者多个操作,在执行的过程中不会被任何的因素打断,这里的任何因素打断具体一点主要是指cpu的线程调度

在Java语言中对基本数据类型读取和赋值才是原子操作,这些操作在执行的过程不会被中断。而像a++或者a+=1类似的操作,都并非是原子性操作。

因为这些操作底层执行的流程分为这三步:读取值计算值赋值。才算完成上面的操作,在多线程的时候就会存在线程安全的问题,产生脏数据,导致最后的结果并非预期的结果。

在面试的过程中也会有很多面试官常常拿volatilesynchronized做比较,在原子性方面区别就是volatile没有办法保证原子性,而synchronized可以实现原子性。

这里简单的只对volatile做一个简介volatile的具体作用主要有两个:保证可见性禁止指令重排,这里画了一个图给大家,可以参考:

具体的volatile为什么没办法保证原子操作,我之前写过一篇关于volatile详细的文章,可以参考这一篇文章[]。

那么synchronized的底层又是怎么实现原子性的呢?这里又要从synchronized的字节码说起,在idea中写了一段简单的代码如下所示:

public class TestSynchronized implements Runnable {

@Override
public void run() {
synchronized (this) {
System.out.println("同步代码块");
}
}

public static void main(String[] args) {
TestSynchronized sync = new TestSynchronized();
Thread t = new Thread(sync);
t.start();
}
}

代码很简单,通过字节码进行分析,执行的字节码如下图所示,在字节码中可以看出在执行代码块中的代码之前有一个monitorenter,后面的是离开monitorexit

不难猜测执行同步代码块中的代码时,首先要获取对象锁,对应使用monitorenter指令 ,在执行完代码块之后,就要释放锁,所对应的指令就是monitorexit

在这里又会有一个面试考点就是:什么会出现两次的monitorexit呢? 这是因为一个线程对一个对象上锁了,后续就一定要解锁,第二个monitorexit是为了保证在线程异常时,也能正常解锁,避免造成死锁

可见性

synchronized实现可见性就是在解锁之前,必须将工作内存中的数据同步到主内存,其它线程操作该变量时每次都可以看到被修改后的值。

说到工作内存和主内存这个要从JMM说起,主存是放共享变量的地方,而工作内存线程私有的,存放的是主存的变量的副本,线程不会对主存的变量直接操作。这里画了一张图给大家理解:

具体讲解JMM的文章我之前写过一篇详细的文章,这里只做上面的概述,详细了解JMM的可以看这一篇[]。

有序性

synchronized在实现有序性时,多线程并发访问只有一个线程执行,从而保证线程执行的顺序都是有序的。

synchronized为了实现有序性,通过阻塞其它线程的方式,来达到线程的有序执行,接下来看一个简单的代码:

public class TestSynchronized implements Runnable {
Object o= new Object();
public static void main(String[] args) throws InterruptedException {
TestSynchronized sync = new TestSynchronized ();
Thread t1 = new Thread(sync);
Thread t2 = new Thread(sync);
t1.start();
t2.start();
}
@Override
public void run() {
synchronized (o) {
try {
System.out.println(Thread.currentThread().getName() + "线程开始执行");
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + "线程等待5秒后执行完毕");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

这个毋庸置疑,当你加了synchronized代码块的时候,这两个线程执行必须是有序的,同一个线程前后的输出一定会在一起,执行的结果如图所示:

假如注释掉synchronized的代码块,两个线程的执行就不再是有序的执行,就会出现如图所示的情况:

可重入性

synchronized的可重入性就是当一个线程已经持有锁对象的临界资源,当该线程再次请求对象的临界资源,可以请求成功,这种情况属于重入锁。

实现的底层原理就是synchronized底层维护一个计数器,当线程获取该锁时,计数器+1,再次获取锁时继续+1,释放锁时,计数器-1,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

synchronized基本用法

前面详细的介绍了synchronized的基本特性,接下来详细的介绍synchronized的基本用法,我们基本都知道大部分是时候只会用到同步方法上,但是它的用法有下面三种:

  1. 同步普通方法:在方法上添加synchronized关键字。
  2. 同步静态方法:在方法上添加synchronized关键字,并且方法被static修饰。
  3. 同步代码块:执行的代码操作被synchronized修饰。
  • 锁定this实例或者实例对象
  • 锁定类字节码

在同步方法中这个相信大家都是知道,代码如下图所示:

private synchronized void syncMethod() {
// 逻辑代码
}

这里有一个问题就是对于synchronized的锁无非就是两种,对于同步方法中的锁对象又是什么呢? ,这里画了一张图给大家,如下如图所示:

在同步普通方法中锁对象就是this,也就是当前对象,哪个对象调用的同步方法,锁对象就是就是它。

当然同步普通方法只能作用在单例上,若不是单例,同步方法就会失效,原因很简单,多例中锁对象不一样,没办法生效

同步静态方法中的锁对象是当前类的class对象,这个相信大家都能想到。

在同步代码块中,可以有很多的玩法,因为锁对象是任意的,由程序员自己操作指定,主要这几种方式获得锁对象:thisObjectthis.getClass()className.getClass()

具体用哪种就要看你的具体的业务场景了,这里只是做了总结和归纳。

synchronized的优化

JVM的书籍中介绍到,synchronizedjdk6之前一直使用的是重量级锁,在jdk6之后便对其进行了优化,新增了偏向锁轻量级锁(自旋锁),并通过锁消除锁粗化自旋锁自适应自旋等方法使用于各种场景,大大提升了synchronized的性能。

下面就来详细的介绍synchronized被优化的过程以及原理,对synchronized优化的实现的具体的原理图如下所示:

在synchronized优化的最重要的就是锁升级的优化过程,也是大厂面试的必问的锁知识点,接下来我们就详细的了解这个过程。

锁升级

在讲解锁升级的过程,先了解对象的在内存中的布局情况,为什么呢?因为锁的信息是存储在对象的markword中,只有了解了对象的布局,对深入的了解锁升级会更有帮助。

在我们创建一个对象后,大部分时候,对象都是分配在堆中,因为还有可能对象在栈上分配,所以这里用大部分情况。

对于一个对象创建完之后,在内存中的布局情况,我之前也写过一篇文章,详细可以参考这一篇[],这里做一个大概的回顾,一个对象在内存中的布局图如下所示。

对象在内存布局中主要分为以下三个部分:对象头(markword、class pointer)、示例数据(instance data)、对齐(可有可无)

其中对象头中,若是对象为数组则还包含数据的长度,其中markword中主要包含信息有:GC年龄信息锁对象信息hashCode信息

class pointer是类型指针,指向当前对象class文件,实例数据若是一个对象有属性private int n=1,这是n=1即使存储在示例数据中。

最后的填充可有可无,这个取决于对象的大小,所示对象大小能被8字节整除,则该部分没有,不能被整除,就会填充对象大小到能够被8字节整除

在对象的内存布局中,最值得我们关注的就是markword,因为markword是存储锁信息的,接下来的实验中,就是要观察markword包含的位里面的大小的变化。

要在实际中观察到对象的内存布局情况,可以借助JOL依赖库,全程是JAVA Objct Layout,即是Java对象布局,只需要在你的maven工程里面引入如下maven坐标:

<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>

然后创建一个SpringBoot项目,加入上面Maven依赖,接着创建Java类JaveObjectLayout,代码如下:

public class JaveObjectLayout {

public static void main(String[] args) {
Object o = new Object();
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
}

}

执行代码后输出的结果如下图所示:

有人问这是啥?不慌,且听我慢慢道来,这个就是Java在内存中的布局数据,前八个字节表示的是markword,其中OFFSET表示起始位,SIZE表示偏移位。

比如第一行0 4,表示第0个字节开始算4个字节,然后第二行4 4表示第4个字节开始算4个字节,这样就一共8个字节,表示完整的markword信息

其中后面的VAlUE数据表示的是对应的这4个字节上的具体位的数据,1字节=8位,这个也刚好对应。

在能看懂这个之前必须要了解各种锁对应的位数上的是0还是1,才能够知道上面输出的表示是什么信息,看一张各种锁表示的信息图:

其中无锁状态位001,偏向锁为101,轻量级锁为00,而重量级锁为10,最后11表示GC信息。这个怎么对应呢?我们再来看上面的那种图:

从代码中可以看出,是没有加锁的,所有对应的最低三位为001为无锁状态,当代码改成如下图所示:

Object o = new Object();
synchronized (o) {
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);
}

再次输出,这时候便表示轻量级锁,前四个字节的数据明显变大,后面字节的数据都没有变化,说明锁信息是存储在markword中的,所谓的加锁,就是在对象的markword中储存锁信息(包括线程的ThreadID),并且对象的锁状态由0改为了1,表示该对象已经被哪个线程所持有。

接下来我们来聊聊详细的锁升级的过程,当初始化完对象后,对象处于无锁状态,在只有一个线程第一次使用该对象,不存在锁竞争时,我们便会认为该线程偏向于它。

偏向锁的实质就是将线程的ThreadID存储于markword中,表明该线程偏向于它

若是某一时刻又来了线程二、线程三也想竞争这把锁,此时是轻度的竞争,便升级为轻量级锁,于是这三个线程就开始竞争了,他们就会去判断锁是否由释放,若是没有释放,没有获得锁的线程就会自旋,这就是自旋锁

在自旋的过程,也会尝试的去获取锁,直到获取锁成功。在jdk1.6之后又出现了自适应自旋,就是jdk根据运行的情况和每个线程运行的情况决定要不要升级

自适应自旋是对自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

这个竞争的过程的实质就是看谁能把自己的ThreadID贴在对象的markword中,而这个过程就是CAS操作,原子操作。

倘若此时又来了线程四、线程5.....线程n,都想获取该锁,竞争越来越激烈了,此时就会升级为重量级锁

所谓的重量级锁,为什么叫做重量级呢?因为重量级锁要通过操作系统,由用户态切换到内核态的过程,这个切换的过程是非常消耗资源的,并且经过系统调用

那么为啥重量级锁那么消耗资源?还要它,要它有何用?是这样的,假如没有重量级锁,不管有多少个线程都是自旋,那么当线程是大了,等待的线程永远在自旋。

自旋是要消耗cpu资源的,这样cpu就撑不住了,反而性能会大大下降,在经过反复的测试后,肯定是有一个临界值,当超过这个临界值时,反而使用重量级锁性能更加高效

因为重量级锁不需要消耗cpu的资源,都把等待的线程放在了一个等待的队列中,需要的时候在唤醒他们。

jdk1.6之前当线程的自选次数超过10次或者等待的自旋的线程数超过了CPU核数的二分之一,就会升级为重量级锁。

当然也有情况就是偏向锁一开始就重度竞争,这是就直接升级为重量级锁,这个在互联网项目中也是很常见的。

经过上面的详细讲解于是就出现了下面的锁升级图,在不同的条件就会升级为不同的锁:

锁消除、锁粗化

锁消除是另一种锁的优化措施,在编译期间会对上下文进行扫描,去除掉不可能存在竞争的锁,这样就不必执行没有必要的上锁和解锁操作消耗性能。

锁粗化就是扩大所得范围,避免反复执行加锁和释放锁,避免不必要的性能消耗。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存