synchronoized 是如何保证原子性、可见性、有序性的?
记得开始学习 Java 的时候,一遇到多线程情况就使用 synchronized,相对于当时的我们来说 synchronized 是这么的神奇而又强大,那个时候我们赋予它一个名字“同步”,也成为了我们解决多线程情况的百试不爽的良药。但是,随着学习的进行我们知道在 JDK1.5 之前 synchronized 是一个重量级锁,相对于 j.u.c.Lock,它会显得那么笨重,以至于我们认为它不是那么的高效而慢慢摒弃它。
不过,随着 Javs SE 1.6 对 synchronized 进行的各种优化后,synchronized 并不会显得那么重了。下面来一起探索 synchronized 的基本使用、实现机制、Java是如何对它进行了优化、锁优化机制、锁的存储结构等升级过程。
Synchronized 是 Java 中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized 的作用主要有三个:
- 原子性:确保线程互斥的访问同步代码;
- 可见性:保证共享变量的修改能够及时可见,其实是通过 Java 内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
- 有序性:有效解决重排序问题,即 “一个 unlock 操作先行发生(happen-before)于后面对同一个锁的 lock 操作”;
在 Java 代码中使用 synchronized 可以使用在代码块和方法中,根据 synchronized 用的位置可以有这些使用场景:
如图,synchronized 可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,具体的可以看上面的表格。这里需要注意的是:如果锁的是类对象的话,尽管 new 多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。
synchronized 锁的是对象而不是代码,锁方法锁的是 this,锁 static 方法锁的是 class。
从语法上讲,synchronized 可以把任何一个非 null 对象作为"锁",在 HotSpot JVM 实现中,锁有个专门的名字:对象监视器(Object Monitor)。
synchronized 概括来说其实总共有三种用法:
- 当 synchronized 作用在实例方法时,监视器锁(monitor)便是对象实例(this);
- 当 synchronized 作用在静态方法时,监视器锁(monitor)便是对象的 Class 实例,因为 Class 数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
- 当 synchronized 作用在某一个对象实例时(即代码块的形式),监视器锁(monitor)便是括号括起来的对象实例;
了解原理之前,我们先要知道两个预备知识:对象头和监视器。
在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
实例数据:存放类的属性数据信息,包括父类的属性信息
对齐填充:填充数据不是必须存在的,它仅仅起着占位符的作用。由于虚拟机要求对象起始地址必须是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
对象头:包含两部分信息:
-
第一部分用于存储对象自身的运行时数据(标记字段,Mard Word),如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
-
对象的另一部分类型指针(Class Pointer),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例(并不是所有的虚拟机实现都必须在对象数据上保留类型指针,也就是说,查找对象的元数据信息并不一定要经过对象本身)。
-
如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。
Java 对象头一般占有 2 个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit,在 64 位虚拟机中,1 个机器码是 8 个字节,也就是 64bit),但是如果对象是数组类型,则需要 3 个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
可以看到对象头中的 Mark Word 记录了对象和锁的有关信息,嗯,你没猜错,我们的 synchronized 和对象头息息相关。
这个类似操作系统中的管程。
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。 管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
在多线程访问共享资源的时候,经常会带来可见性和原子性的安全问题。为了解决这类线程安全的问题,Java 提供了同步机制、互斥锁机制,这个机制保证了在同一时刻只有一个线程能访问共享资源。这个机制的保障来源于监视锁 Monitor。
我们也用某国外大佬的例子来说明 Moniter 是什么
监视器可以看做是经过特殊布置的建筑,这个建筑有一个特殊的房间,该房间通常包含一些数据和代码,但是一次只能一个消费者(thread)使用此房间,
当一个消费者(线程)使用了这个房间,首先他必须到一个大厅(Entry Set)等待,调度程序将基于某些标准(e.g. FIFO)将从大厅中选择一个消费者(线程),进入特殊房间,如果这个线程因为某些原因被“挂起”,它将被调度程序安排到“等待房间”,并且一段时间之后会被重新分配到特殊房间,按照上面的线路,这个建筑物包含三个房间,分别是“特殊房间”、“大厅”以及“等待房间”。
简单来说,监视器用来监视线程进入这个特别房间,他确保同一时间只能有一个线程可以访问特殊房间中的数据和代码。
任何一个对象都有一个 Monitor 与之关联,当且一个 Monitor 被持有后,它将处于锁定状态。synchronized 在JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。
- MonitorEnter 指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor 的所有权,即尝试获得该对象的锁;
- MonitorExit 指令:插入在方法结束处和异常处,JVM 保证每个MonitorEnter 必须有对应的MonitorExit;
那什么是 Monitor?可以把它理解为 一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与万物皆对象一样,所有的 Java 对象是天生的 Monitor,每一个 Java 对象都有成为 Monitor 的潜质,因为在 Java 的设计中 ,每一个 Java 对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁。
在 Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由 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 ;
}源码地址:objectMonitor.hpp
ObjectMonitor中有几个关键属性(每个等待锁的线程都会被封装成 ObjectWaiter 对象):
_owner:指向持有 ObjectMonitor 对象的线程
_WaitSet:存放处于 wait 状态的线程队列
_EntryList:存放处于等待锁 block 状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数
当多个线程同时访问一段同步代码时,首先会进入 _EntryList 队列中,当某个线程获取到对象的 monitor 后进入 _Owner 区域并把 monitor 中的 _owner 变量设置为当前线程,同时 monitor 中的计数器 _count 加 1。即获得对象锁。
若持有 monitor 的线程调用 wait() 方法,将释放当前持有的 monitor,_owner 变量恢复为 null,_count 自减 1,同时该线程进入 _WaitSet 集合中等待被唤醒。
若当前线程执行完毕也将释放 monitor(锁) 并复位变量的值,以便其他线程进入获取 monitor(锁)。
如上图所示,一个线程通过 1 号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的 Owner,然后执行监视区域的代码。
如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过 5 号门退出监视器;
还有可能等待某个条件的出现,于是它会通过 3 号门到 Wait Set(等待区)休息,直到相应的条件满足后再通过 4号门进入重新获取监视器再执行。
注意:
当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从 2号门进入;如果等待区的线程赢了会从 4 号门进入。只有通过 3 号门才能进入等待区,在等待区中的线程只有通过 4 号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行 wait 操作,处于等待的线程只有再次获得监视器才能退出等待状态。
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
线程是 CPU 调度的基本单位。CPU 有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去 CPU 使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
在 Java 中,为了保证原子性,提供了两个高级的字节码指令 monitorenter 和 monitorexit。前面中,介绍过,这两个字节码指令,在 Java 中对应的关键字就是 synchronized。
通过 monitorenter 和 monitorexit 指令,可以保证被 synchronized 修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用 synchronized 来保证方法和代码块内的操作是原子性的。
线程 1 在执行
monitorenter指令的时候,会对 Monitor 进行加锁,加锁后其他线程无法获得锁,除非线程1 主动解锁。即使在执行过程中,由于某种原因,比如 CPU 时间片用完,线程 1 放弃了 CPU,但是,他并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java 内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程 1 改了某个变量的值,但是线程 2 不可见的情况。
前面我们介绍过,被 synchronized 修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。
所以,synchronized 关键字锁住的对象,其值是具有可见性的。
有序性即程序执行的顺序按照代码的先后顺序执行。
除了引入了时间片以外,由于处理器优化和指令重排等,CPU 还可能对输入代码进行乱序执行,比如 load->add->save 有可能被优化成 load->save->add。这就是可能存在有序性问题。
这里需要注意的是,synchronized 是无法禁止指令重排和处理器优化的。也就是说,synchronized 无法避免上述提到的问题。
那么,为什么还说 synchronized 也提供了有序性保证呢?
这就要再把有序性的概念扩展一下了。Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。
以上这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?周志明并没有详细的解释。这里我简单扩展一下,这其实和 as-if-serial语义 有关。
as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
这里不对 as-if-serial语义 详细展开了,简单说就是,as-if-serial语义 保证了单线程中,指令重排是有一定的限制的,而只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的。当然,实际上还是有重排的,只不过我们无须关心这种重排的干扰。
所以呢,由于 synchronized 修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。
预备知识结束,其实 synchronized 的原理也就差不多了,我们再细看
从上文 synchronized 的使用中,我们可以看到从加锁位置的不同,它其实可以只有两种:锁住的是类,或者锁住的是对象。我们把它称为类锁和对象锁。
在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。
在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。
接下来从代码,看下 synchronized 的实现
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
}
method();
}
private static void method() {
}
}上面的代码中有一个同步代码块,锁住的是类对象,并且还有一个静态方法,锁住的依然是该类的类对象。编译之后,切换到 SynchronizedDemo.class 的同级目录之后,然后用 javap -v SynchronizedDemo.class 查看字节码文件:
如图,上面用黄色高亮的部分就是需要注意的部分了,这也是添加 synchronized 关键字之后独有的。执行同步代码块后首先要先执行 monitorenter 指令,退出的时候执行 monitorexit 指令。通过分析之后可以看出,使用synchronized 进行同步,其关键就是必须要对对象的监视器 monitor 进行获取,当线程获取 monitor 后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到 monitor。上面的 demo 中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条 monitorexit 指令,并没有 monitorenter 获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。synchronized 先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。
-
monitorenter:每个对象都是一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下:
- 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor的所有者;
- 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1;
- 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权;
-
monitorexit:执行 monitorexit 指令的线程必须是对象实例所对应的监视器的所有者。指令执行时,monitor的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其他被这个monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权。
通过上面两段描述,我们应该能很清楚的看出 synchronized 的实现原理,synchronized 的语义底层是通过一个monitor 的对象来完成,其实 wait/notify 等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。
JVM需要保证每一个 monitorenter 都有一个 monitorexit 与之相对应,但每个 monitorexit 不一定都有一个monitorenter,比如 JVM 规范中的代码 中的示例,我们也会看到有两次 monitorexit,它的注释是 Be sure to exit the monitor!
看到有个这样的解释:
该代码没有两次“调用”
monitorexit指令。它在两个不同的代码路径上执行一次。
- 第一个代码路径用于
synchronized块中的代码正常退出时。- 对于块异常终止的情况,第二个代码路径位于隐式异常处理路径中。
您可以在示例中将字节码写为 pseudo-code ,如下所示:
void onlyMe(Foo f) { monitorEntry(f); try { doSomething(); monitorExit(); } catch (Throwable any) { monitorExit(); throw any; } }
再来看一下同步方法:
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
}查看反编译后结果:
从编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM 就是根据该标示符来实现方法的同步的
The Java® Virtual Machine Specification中有关于方法级同步的介绍:
Method-level synchronization is performed implicitly, as part of method invocation and return. A synchronized method is distinguished in the run-time constant pool’s method_info structure by the ACC_SYNCHRONIZED flag, which is checked by the method invocation instructions. When invoking a method for which ACC_SYNCHRONIZED is set, the executing thread enters a monitor, invokes the method itself, and exits the monitor whether the method invocation completes normally or abruptly. During the time the executing thread owns the monitor, no other thread may enter it. If an exception is thrown during invocation of the synchronized method and the synchronized method does not handle the exception, the monitor for the method is automatically exited before the exception is rethrown out of the synchronized method.
主要说的是: 方法级的同步是隐式的。同步方法的常量池中会有一个 ACC_SYNCHRONIZED 标志。当某个线程要访问某个方法的时候,会检查是否有 ACC_SYNCHRONIZED,如果有设置,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
注意,synchronized 内置锁是一种对象锁(锁的是对象而非引用变量),作用粒度是对象 ,可以用来实现对临界资源的同步互斥访问 ,是可重入的。其可重入最大的作用是避免死锁,如:
子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁;
面试官:父类中有一个加锁的方法A,而子类中也有一个加锁的方法B,B在执行过程中,会调用A方法,问此时会不会产生死锁?
不会,创建子类对象时,不会创建父类对象,其实创建子类对象的时候,JVM会为子类对象分配内存空间,并调用父类的构造函数。我们可以这样理解:创建了一个子类对象的时候,在子类对象内存中,有两份数据,一份继承自父类,一份来自子类,但是他们属于同一个对象(子类对象)。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是 JVM 通过调用操作系统的互斥原语 mutex 来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
所以频繁的通过Synchronized实现同步会严重影响到程序效率,这种锁机制也被称为重量级锁,为了减少重量级锁带来的性能开销,JDK对Synchronized进行了种种优化。
从 JDK5 引入了现代操作系统新增加的 CAS 原子操作( JDK5 中并没有对 synchronized关键字做优化,而是体现在 J.U.C 中,所以在该版本 concurrent 包有更好的性能 ),从 JDK6 开始,就对 synchronized 的实现机制进行了较大调整,包括使用 JDK5 引进的 CAS 自旋之外,还增加了自适应的 CAS 自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过 -XX:-UseBiasedLocking 来禁用偏向锁。
线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
所以引入了自旋锁,何谓自旋锁?
所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了 CPU 处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在 JDK 1.4.2 中引入,默认关闭的,但是可以使用 -XX:+UseSpinning 开启,在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin 来调整。
如果通过参数 -XX:PreBlockSpin 来调整自旋锁的自旋次数,会带来诸多不便。假如将参数调整为 10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁),是不是很尴尬。于是 JDK1.6引入了自适应的自旋锁,让虚拟机变得越来越聪明。
JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的了,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。那它如何进行适应性自旋呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要获取这个锁的时候自旋的次数就会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM 检测到不可能存在共享数据竞争,这时 JVM 就会对这些同步锁进行锁消除。
锁消除的依据是逃逸分析的数据支持
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于程序员来说这还不清楚么?在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?虽然没有显示使用锁,但是在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐形的加锁操作。比如 StringBuffer 的 append() 方法,Vector的 add() 方法:
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}在运行这段代码时,JVM 可以明显检测到变量 vector 没有逃逸出方法 vectorTest() 之外,所以 JVM可以大胆地将vector 内部的加锁操作消除。
很多时候,我们提倡尽量减小锁的粒度,可以避免不必要的阻塞。 让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是如果在一段代码中连续的用同一个监视器锁反复的加锁解锁,甚至加锁操作出现在循环体中的时候,就会导致不必要的性能损耗,这种情况就需要锁粗化。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
举例:
for(int i=0;i<100000;i++){
synchronized(this){
do();
}
}会被粗化成:
synchronized(this){
for(int i=0;i<100000;i++){
do();
}
}这里我们再回过头看下对象头中 Mark Word 的存储结构(以 32 位虚拟机为例,《Java并发编程艺术》)
从 Java 对象头的 Mark word 中可以看到,synchronized 锁一共具有四种状态:无锁、偏向锁、轻量级锁、重量级锁。
偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
偏向锁是 JDK6 中的重要引进,因为 HotSpot 作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
-
偏向锁主要用来优化同一线程多次申请同一个锁的竞争,在某些情况下,大部分时间都是同一个线程竞争锁资源
-
偏向锁的作用
- 当一个线程再次访问同一个同步代码时,该线程只需对该对象头的Mark Word中去判断是否有偏向锁指向它
- 无需再进入Monitor去竞争对象(避免用户态和内核态的切换)
-
当对象被当做同步锁,并有一个线程抢到锁时
- 锁标志位还是01,是否偏向锁标志位设置为1,并且记录抢到锁的线程ID,进入偏向锁状态
-
偏向锁
不会主动释放锁
-
当线程1再次获取锁时,会比较当前线程的ID与锁对象头部的线程ID是否一致,如果一致,无需CAS来抢占锁
-
如果不一致,需要查看锁对象头部记录的线程是否存活
-
如果没有存活,那么锁对象被重置为无锁状态(也是一种撤销),然后重新偏向线程2
-
如果存活,查找线程1的栈帧信息
- 如果线程1还是需要继续持有该锁对象,那么暂停线程1(STW),撤销偏向锁,升级为轻量级锁
-
-
- 如果线程1不再使用该锁对象,那么将该锁对象设为无锁状态(也是一种撤销),然后重新偏向线程2
-
一旦出现其他线程竞争锁资源时,偏向锁就会被撤销
- 偏向锁的撤销可能需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法
- 如果还没有执行完,说明此刻有多个线程竞争,升级为轻量级锁;如果已经执行完毕,唤醒其他线程继续CAS抢占
-
在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁会被撤销,发生 STW ,加大了性能开销
-
默认配置
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=4000- 默认开启偏向锁,并且延迟生效,因为JVM刚启动时竞争非常激烈
-
关闭偏向锁
-XX:-UseBiasedLocking
-
直接设置为重量级锁
-XX:+UseHeavyMonitors
-
目的:在大多数情况下同步块并不会出现竞争情况,大部分情况是不同线程交替持有锁,所以引入轻量级锁可以减少重量级锁对线程的阻塞带来的开销。
轻量级锁认为环境中线程几乎没有对锁对象的竞争,即使有竞争也只需要稍微等待(自旋)下就可以获取锁,但是自旋次数有限制,如果超过该次数,则会升级为重量级锁。
-
当有另外一个线程竞争锁时,由于该锁处于偏向锁状态
-
发现对象头Mark Word中的线程ID不是自己的线程ID,该线程就会执行 CAS 操作获取锁
- 如果获取成功,直接替换Mark Word中的线程ID为自己的线程ID,该锁会保持偏向锁状态
- 如果获取失败,说明当前锁有一定的竞争,将偏向锁升级为轻量级锁
-
线程获取轻量级锁时会有两步
- 先把锁对象的Mark Word复制一份到线程的栈帧中(DisplacedMarkWord),主要为了保留现场!!
- 然后使用CAS,把对象头中的内容替换为线程栈帧中DisplacedMarkWord的地址
-
场景
- 在线程1复制对象头Mark Word的同时(CAS之前),线程2也准备获取锁,也复制了对象头Mark Word
- 在线程2进行CAS时,发现线程1已经把对象头换了,线程2的CAS失败,线程2会尝试使用自旋锁来等待线程1释放锁
-
轻量级锁的适用场景:线程交替执行同步块,绝大部分的锁在整个同步周期内都不存在长时间的竞争
Synchronized 是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 JDK5 之前 synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。
-
轻量级锁 CAS 抢占失败,线程将会被挂起进入阻塞状态
- 如果正在持有锁的线程在很短的时间内释放锁资源,那么进入阻塞状态的线程被唤醒后又要重新抢占锁资源
-
JVM 提供了自旋锁,可以通过自旋的方式不断尝试获取锁,从而避免线程被挂起阻塞
-
从 JDK 1.6 开始,自旋锁默认启用,自旋次数不建议设置过大(意味着长时间占用CPU)
-XX:+UseSpinning -XX:PreBlockSpin=10
-
自旋锁重试之后如果依然抢锁失败,同步锁会升级至重量级锁,锁标志位为 10 在这个状态下,未抢到锁的线程都会进入Monitor,之后会被阻塞在WaitSet中
-
在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能
- 一旦锁竞争激烈或者锁占用的时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源
-
在高并发的场景下,可以通过关闭自旋锁来优化系统性能
-
-XX:-UseSpinning- 关闭自旋锁优化
-
-XX:PreBlockSpin- 默认的自旋次数,在JDK 1.7后,由JVM控制
-
重量级锁、轻量级锁和偏向锁之间转换
各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。
- 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连 CAS 都不用做,仅仅在内存中比较下对象头就可以了;
- 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
- 如果其他线程通过一定次数的 CAS 尝试没有成功,则进入重量级锁;
在第 3 种情况下进入同步代码块就要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁,最终还是得靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大不少了。所以使用哪种技术,一定要看其所处的环境及场景,在绝大多数的情况下,偏向锁是有效的,这是基于 HotSpot 作者发现的“大多数锁只会由同一线程并发申请”的经验规律。
| 锁 | 优点 | 缺点 | 应用场景 |
|---|---|---|---|
| 偏向锁 | 加锁与解锁基本不消耗资源 | 如果存在线程竞争则撤销锁需要额外的消耗 | 只有一个线程访问同步块的情景 |
| 轻量级锁 | 竞争锁不需要线程切换,提供了执行效率 | 如果存在大量线程竞争锁,自旋会消耗CPU资源 | 追求响应时间。适用于少量线程访问同步块,追求访问同步块的速度 |
| 重量级锁 | 线程不需要自旋,不会消耗过多cpu资源 | 线程切换需要消耗大量资源,线程阻塞,执行缓慢 | 追求吞吐量。同步块执行时间较长的情况。 |
-
减少锁持有时间:尽可能减少同步代码块,加快同步代码块执行速度。
-
减少锁的粒度:分段锁概念
-
锁粗化
-
锁分离(读写锁)
-
使用CAS + 自旋的形式
-
消除缓存行的伪共享: 每个CPU都有自己独占的一级缓存,二级缓存,为了提供性能,CPU读写数据是以缓存行尾最小单元读写的;32位的cpu缓存行为32字节,64位cpu的缓存行为64字节,这就导致了一些问题,例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁;
为了防止伪共享,不同jdk版本实现方式是不一样的:
- 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;
- 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;
- 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数:
-XX:-RestrictContended
sun.misc.Contended注解会在变量前面添加128字节的 padding 将当前变量与其他变量进行隔离;
https://juejin.im/post/5ae6dc04f265da0ba351d3ff
https://blog.csdn.net/zhengwangzw/article/details/105141484
https://www.cnblogs.com/aspirant/p/11470858.html
https://www.hollischuang.com/archives/2030
http://zhongmingmao.me/2019/08/15/java-performance-synchronized-opt/












