船长会持续更新和分享技术文章,点赞关注不迷路~
捞捞之前的几篇文章:
并发在程序中很重要,而Synchronized在并发体系中是元老级的角色,吃透了Synchronized关键字的底层原理,同时也会对整个并发体系有更深的感悟
本篇主要讲Java的多线程加锁关键字Synchronized,而Java多线程加锁机制主要有两种:
-
Synchronized
-
显示Lock
本文会按照了解和使用、锁对象和特性、底层实现和锁优化、不得不提及的Lock接口四个方面来介绍Synchronized关键字,其中底层实现和优化这块需要重点理解的哦~
了解和使用
Synchronized是Java的一个关键字,它能够将方法(代码块)锁起来,这样便可以一次只允许一个线程进入被锁住的代码块,即可以实现同步的功能
这是一个内置监视器锁,java中每一个对象都有一个内置锁,而Synchronized就是使用对象的内置锁来将方法或者代码块锁住的
我们来看如何使用这个关键字,一般Synchronized使用场景有三种:
-
修饰普通同步方法,锁是当前实例对象
-
**修饰静态同步方法,锁是当前类的Class对象
** -
修饰同步方法块,锁是Synchronized括号中配置的对象
1、修饰普通同步方法,锁的是SynchronizedTest对象(内置锁)
public class SynchronizedTest { public synchronized void getGirl() { }}
2、修饰静态同步方法,对SynchronizedTest.class(当前类的Class对象)加锁
public class SynchronizedTest { public void getGirl() { synchronized (SynchronizedTest.class) { } }}
括号中的SynchronizedTest.class也可以是this,或者一些new对象,此时锁对象便不是Class对象,而是实体类对象
3、修饰同步方法块,指定Girl类对象,给这个对象加锁
public class SynchronizedTest { public void getGirl() { synchronized (new Girl()) { } }}
需要特别说明的是,无论是对一个对象加锁还是对一个方法加锁,实际上最后都是对对象加锁,也就是根据Synchronized修饰的来决定是实例方法还是Class对象,去给对应的实例对象或者Class对象进行加锁
我们对于Synchronized这个关键字或多或少的也接触过,可能大家之前听说过这是一个重量级锁,但是随着jdk1.6的优化,已经改进了很多在性能方面,比如偏向锁、轻量级锁、重量级锁、自旋锁、自适应自旋锁等,这些锁分别是什么意思呢,有哪些区别呢?
既然锁是加在对象上的,那么一个线程是如何知道这个对象被加锁了的,又是如何知道它加的锁的类型的呢?
锁对象和特性
上面说到锁实际上是加在对象上的,那么被加了锁的对象我们可以称为锁对象,在Java中任何一个对象都能成为锁对象,它们是如何加锁的呢?
我们先来一起看看锁对象的结构和特性,在JVM中,对象在内存中分为三块区域:
-
对象头:主要是一些运行时的数据
-
**实例数据:主要存放类的数据信息、父类的信息
** -
**对其填充数据:**由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
(大家有没有被问过一个空对象占几个字节?8个字节,是因为存在对其填充数据,不到8个字节数据会自动补齐)
我们重点看对象头:主要用于存储运行时的数据
长度
内容
说明
32/64bit
Mark Word
存储对象的hashCode或锁信息
32/64bit
Class Metadata Address
存储对象类型数据的指针
32/64bit
Array Length
数组的长度(当对象为数组时)
Java对象头里的Mark Word里默认存储的是对象的HashCode、分代年龄和锁标记位,我们分别看32位和64位的对象头的Mark Word存储结构:
32bit结构图
64bit结构图
对于锁来说,关键部分是后面的几个bit标志位
锁状态
是否是偏向锁
锁标志位
无锁
0
01
偏向锁
1
01
GC标记
11
轻量级锁
00
重量级锁
10
这是标志位和锁状态的对应信息,偏向锁是1的时候代表偏向锁生效,此时标志位为01,当偏向锁这个标志位是0时代表此时是无锁状态。处于偏向锁时我们也可以通过epoch确定哪个线程获得该对象的锁
偏向锁、轻量级锁这些是属于锁的优化策略,我们下面详解;我们这里了解了锁对象的结构,接下来看锁的一些特性
-
有序性:为了提高性能,编译器和处理器常常会对代码进行指令重排序
-
可见性:JMM内存模型中多个线程工作内存的可见性
-
原子性:确保同一时间只有一个线程能拿到锁,进入代码块
1、有序性:为了提高性能,编译器和处理器常常会对代码进行指令重排序
内存模型会适当的放松对编译器和处理器的束缚,从而它们可以对指令进行重排序,尽可能高的提高执行效率,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序,一般重排序分为下面三种:
-
编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
-
指令级并行的重排序:现代处理器采用了指令级并行技术将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
-
内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的
第一个属于编译器重排序,第二、三个属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题,JMM是语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,经过禁止特定类型的编译器和处理器重排序为程序提供一直的内存可见性保证
JMM:Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节
对于编译器,JMM编译器会禁止特定类型的编译器重排序(不是所有编译器重排序都要禁止);对于处理器,JMM处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障来禁止特定类型的处理器重排序
对于重排序问题,不得不提的一个概念:as-if-serial
as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义
2、可见性:JMM内存模型中多个线程工作内存的可见性
重排序是为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见行
如果我们自己去了解底层的实现以及具体规则,那么负担太重了,严重影响了并发编程的效率,于是有了happens-before的概念,通过这个概念来解释操作之间的内存可见性
happens-before:如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须存在happens-befores关系
比如看如下代码示例:
double pi = 3.14; //A double r = 1.0; //B double area = pi * r * r; //C
根据happens-before的程序顺序规则,上面的计算圆的面积的示例代码的3个happens-before关系:
-
A happens-before B
-
B happens-before C
-
A happens-before C
这里的第三个happens-before关系,是根据前面两个的传递性推导出来的
这里A happens-before B,但实际执行B却可以排在A之前执行,如果A happens-before B,JMM并不要求A一定要在B之前执行,JMM仅仅要求前一个操作对后一个操作可见,且前一个操作案顺序排在第二个操作之前
如果我现在的flag变成了false,那么后面的操作,一定知道这个值变了
3、原子性:一次操作,要么完全成功,要么完全失败
其实他保证原子性很简单,确保同一时间只有一个线程能拿到锁,能够进入代码块这就够了。
上面的三个特性是锁的一些特性,synchronized全都具备,而且synchronized还具备一些额外的特性,来看看
1、**可重入性:**synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。可重入锁的好处是可以避免一些死锁的情况,也可以让我们更好的管理我们的代码
2**、不可中断性:**不可中断就是指,一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。值得一提的是,Lock的tryLock方法是可以被中断的。
底层实现和锁优化
知其然,我们还要知其所以然,来吧,看底层~
简单写了一个类,分别有锁方法、锁代码块(实例对象和Class对象),我们来反编译一下字节码文件来看:
public class SynchronizedTest { public synchronized void getGirl() { } public void getGirl2() { synchronized (SynchronizedTest.class) { } } public void getGirl3() { synchronized (new Girl()) { } }}
我们通过javap -v SynchronizedTest.class来查看反编译的文件:
我们一起看我标记的这几处,我在上面讲到了一个对象包含对象头,它会关联到一个monitor对象
-
同步方法(Synchronized修饰方法)中有一个标志位ACC_SYNCHRONIZED,一旦执行到这个方法,会先判断是否有标志位,然后****ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit
-
**同步代码(锁实例对象或Class对象)中是直接通过关联的monitor对象来进行获得锁的,进入方法时执行monitorenter时就会获取当前对象的一个所有权,这时monitor进入数为1,****当前的这个线程就是这个monitor的owner。如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1;**当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。
互斥,其实就是这里,就是看你能否获得monitor的所有权,一旦你成为owner就是获得者。
monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中:
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; }
Synchronized锁在以前一直是重量级锁的代表,会比较耗性能,但是在jdk1.6引入了很多的锁的优化,我们先来看一下锁的升级过程:
锁的升级方向是这个样子的:
锁的升级是不可逆的,只允许锁升级不允许锁降级
接下来我们来看具体的锁的升级过程
偏向锁
上面提到对象头的Mark Word部分是这个样子的:
偏向锁是jdk1.6引入的一个优化,它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。
一旦一个线程持有了一个对象的锁,标志位修改为1,就进入了偏向锁模式,同时把线程的ID记录在对象的Mark Word中,在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不再需要去进行加锁或者解锁操作,而是接下来做下面的动作:
-
Load-and-test,判断一下当前线程id是否与Markword当中的线程id是否一致
-
如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码
-
如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁”标志位的值。
-
如果还是未偏向的状态,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作。
偏向锁针对一个线程来说不会有解锁动作,可以省略很多开销,假设有两个线程竞争偏向锁,偏向锁失效会升级为轻量级锁
为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的,这也是为什么会有偏向锁出现的原因。
在Jdk1.6中,偏向锁的开关是默认开启的,适用于只有一个线程访问同步块的场景。
轻量级锁
上面说到,当出现有两个线程来竞争锁的话,那么偏向锁就失效了,此时锁就会膨胀,升级为轻量级锁。这也是我们经常所说的锁膨胀
偏向锁失效之后,需要把锁撤销掉,锁撤销的开销还是挺大的,大概过程是这个样子的:
-
在一个安全点停止拥有锁的线程。
-
遍历线程栈,如果存在锁记录的话,需要修复锁记录和Markword,使其变成无锁状态。
-
唤醒当前线程,将当前锁升级成轻量级锁。
如果这个对象是无锁的,jvm就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的Mark Word 拷贝,然后把Lock Record中的owner指向当前对象。
JVM接下来会利用CAS尝试把对象原本的Mark Word 更新会Lock Record的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。
如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。
自旋锁和自适应自旋锁
轻量级锁主要有两种:
-
自旋锁
-
自适应自旋锁
线程的等待和唤起是很消耗资源的,我们如何减少这种消耗呢?这里便引入了自旋,自旋即指当一个线程来竞争锁时,这个线程会在原地等待,而不是把该线程给阻塞,知道拥有锁的线程释放之后,这个锁会马上获得锁
锁在原地循环时,是会消耗CPU的,相当于一个啥也没执行的for循环,所以轻量级锁适用于那些同步代码快执行的很快的场景,这样线程原地等待很短的时间就能够获得锁了,自旋锁的默认大小是10次,
-XX: PreBlockSpin可以修改
自旋锁存在的一些问题:
-
如果同步代码快执行很慢,需要消耗大量时间,这个时侯,其他线程在原地等待空消耗cpu
-
本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁。
自适应自旋锁就是线程空循环的次数并非固定的,而是动态的根据实际情况来改变自旋等待的次数
其大概原理是这样的:
假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。
重量级锁
轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。
这一路的演变是不可逆的,重量级锁的开销大,为什么呢?主要是因为当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
Linux系统大家了解吧,分为用户空间和内核状态,所有程序在用户空间运行,进入用户状态可能会涉及到内核运行,比如磁盘IO,就会进入内核运行状态
絮叨叨
你知道的越多,你不知道的也越多。
看心情:2020,活着真的很重要啊,希望大家都身体健康,挣大钱啊
觉得不错的可以给船长来个关注,也欢迎可爱帅气的你推荐给你的朋友,转发和点赞是可以给船长多打打气~~