前言
说起Java面试中最高频的知识点非多线程莫属。每每提起多线程都绕不过一个Java关键字——synchronized
。我们都知道该关键字可以保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块以保证多线程的安全性。那么,本篇文章我们就来揭开这个synchronized
的面纱。
线程安全的实现方法
在详细介绍synchronized
之前,我们首先了解一下实现线程安全的不同方式,了解synchronized
是如何实现线程安全的理论基础,做到心中有数。目前主要有三种线程安全实现方法:互斥同步(阻塞同步)、非阻塞同步以及无需同步的线程安全方案。
- 互斥同步(Mutual Exclusion & Synchnronization)
互斥同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此在互斥同步四个字中,互斥是因,同步是果;互斥是方法,同步是目的。
Java中最基本的互斥同步手段就是synchronized
,具体如何实现的互斥同步请继续往下看。
btw,除了synchronized
,还有另外一种实现同步的方式,那就是java.util.concurrent
包中的重入锁ReentrantLock
,具体细节就不细说了,它和synchronized
用法几乎一样。只是synchronized
是原生语法,而ReentrantLock
是JDK提供的API层面的互斥锁。
- 非阻塞同步
互斥同步主要同步阻塞线程来保证线程安全,因此也被称为阻塞同步。它认为只要不去做正确的同步方式(例如加锁),那就一定会出现问题,无论共享数据是否会出现竞争(悲观锁)。
回来随着硬件指令集的发展,我们有了另外一种选择:先进行操作,如果没有其他线程争用,那操作就成功了;如果有其他线程争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要把线程挂起,所以这种同步方式成为非阻塞同步。
- 无需同步的线程安全方案
要保证线程安全,并不一定就要进行同步,两者并没有因果关系。如果一个方法本来就不涉及共享数据,那它自然无需任何同步手段去保证正确性,因此会有一些代码天生线程安全。比如可重入代码(Reentrant Code)和线程本地存储(Thread Local Storage)等。
JDK中的synchronized改进
在 JDK1.5 之前,Java 是依靠 Synchronized 关键字实现锁功能来做到线程安全。Synchronized 是 JVM 实现的一种内置锁,锁的获取和释放是由 JVM 隐式实现。
到了 JDK1.5 版本,java.util.concurrent
包中新增了 Lock 接口来实现锁功能,它提供了与 Synchronized 关键字类似的同步功能,只是在使用时需要显示获取和释放锁。前边我们提到过,Lock 同步锁是基于 Java 实现的,而 Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。因此,在锁竞争激烈的情况下,Synchronized 同步锁在性能上就表现得非常糟糕,它也常被大家称为重量级锁。特别是在单个线程重复申请锁的情况下,JDK1.5 版本的 Synchronized 锁性能要比 Lock 的性能差很多。例如,在 Dubbo 基于 Netty 实现的通信中,消费端向服务端通信之后,由于接收返回消息是异步,所以需要一个线程轮询监听返回信息。而在接收消息时,就需要用到锁来确保 request session 的原子性。如果我们这里使用 Synchronized 同步锁,那么每当同一个线程请求锁资源时,都会发生一次用户态和内核态的切换。
到了 JDK1.6 版本之后,Java 对 Synchronized 同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了 Lock 同步锁。
synchronized使用方式
Java中万物皆对象,而每一个对象都可以加锁,这是synchronized
保证线程安全的基础。
对于同步方法,锁是当前实例对象,即
this
,对该类其他实例对象无影响。对于静态同步方法,锁是当前对象的 Class 对象, 影响其他该类的实例化对象。
对于同步方法块,锁是
synchronized
括号里配置的对象。
也就是说,我们可以利用synchronized
修饰类,类中的方法或者方法块。如下面的代码,分别对应上述三种情形。
public class synchronizedTest implements Runnable {
static synchronizedTest instance=new synchronizedTest();
public void run() {
synchronized(instance){
//同步代码块,对应文章中第3点
//*******
}
}
void synchronized method1() {} //类中的同步方法 对应文章中第1点
void static synchronized method2() {} ////类中静态同步方法 对应文章中第2点
}
同步方法块
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁存在哪里呢?锁里面会存储什么信息呢?我们先来看一段代码以及它的字节码(我这里用的Idea的jclasslib插件)。
package techgo.blog;
public class SynchronizedTest {
private int i = 0;
public void fun() {
synchronized (this) {
i ++;
}
}
}
我们看到monitorenter和monitorexit,之后查阅虚拟机字节码指令表,我们知道这两个字节码操作分别表示获得和释放对象的锁。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。以上这是同步方法块的实现方式。
同步方法
对于同步方法来说,如果去查看其字节码,我们会看不到这两个指令,因为同步方法依靠的是方法修饰符上的ACC_SYNCHRONIZED来实现的:
public synchronized void fun1() {
}
当方法调用时,调用指令将会检查该方法是否被设置 ACC_SYNCHRONIZED 访问标志。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。
synchronized锁的实现
synchronized的对象锁,其指针指向的是一个monitor对象(由C++实现)的起始地址。每个对象实例都会有一个 monitor。其中monitor可以与对象一起创建、销毁;亦或者当线程试图获取对象锁时自动生成。需要注意的是monitor不是Java特有的概念,想了解更多monitor的详细介绍可以查看这篇文章。
在HotSpot虚拟机中,最终采用ObjectMonitor类实现monitor。
openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp源码如下:
ObjectMonitor() {
_count = 0;
_owner = NULL;//指向获得ObjectMonitor对象的线程或基础锁
_EntryList = NULL ;//处于等待锁block状态的线程,会被加入到entry set;
_WaitSet = NULL;//处于wait状态的线程,会被加入到wait set;
_WaitSetLock = 0 ;
_header = NULL;//markOop对象头
_waiters = 0,//等待线程数
_recursions = 0;//重入次数
_object = NULL;//监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;// _owner is (Thread *) vs SP/BasicLock
_previous_owner_tid = 0;// 监视器前一个拥有者线程的ID
}
当多个线程同时访问一段同步代码时,多个线程会先被存放在 ContentionList 和 _EntryList 集合中,处于 block 状态的线程,都会被加入到该列表。接下来当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex,竞争失败的线程会再次进入 ContentionList 被挂起。
如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放 Mutex。
继续深入(锁优化)
我们都知道,对象被创建在堆中。并且对象在内存中的存储布局方式可以分为3块区域:对象头、实例数据、对齐填充。
对于对象头来说,主要是包括俩部分信息Mark Word和Klass Point:
- Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
- 另一部分是类型指针Klass Point:JVM通过这个指针来确定这个对象是哪个类的实例。
锁升级功能主要依赖于 Mark Word 中的锁标志位和释放偏向锁标志位,Synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。好了今天就先到这了,锁优化的细节还在码字中。。
参考资料:
《深入理解Java虚拟机》 第二版
https://blog.csdn.net/wangyadong317/article/details/84065828
https://blog.csdn.net/zjy15203167987/article/details/82531772
https://www.cnblogs.com/JsonShare/p/11433302.html
https://baijiahao.baidu.com/s?id=1612142459503895416&wfr=spider&for=pc
https://www.php.cn/java-article-410323.html
本文由博客一文多发平台 OpenWrite 发布!
文章首发:https://zhuanlan.zhihu.com/lovebell
个人公众号:技术Go
您的点赞与支持是作者持续更新的最大动力!