应用场景
JAVA中常用的锁机制 Synchronized
Synchronized加锁方式总结面试题 下面主要有下面几个内容
对象内存结构JVM开启逃逸分析JVM锁的优化-锁的粗化与消除JVM内置锁优化升级过程偏向锁---总是同一个线程多次获得锁轻量级锁---出现线程间交替执行自旋锁----让线程等待一段时间(空旋转一段时间)且这个等待时间的消耗小于切换成重量级锁的时间消耗锁消除 ReentrantLock
总结ReentrantLock AbstractQueuedSynchronizer
同步等待队列自定义同步器 各种锁定义
公平锁非公平锁可重入锁不可重入锁读写锁 总结:
Synchronized是隐式锁,基于AQS的显示锁 参考文档
应用场景- 在减库存场景下,库存剩余数量字段,就好比一个共享变量,也可以称之为临界变量加锁的目的:让线程能够串行化的访问临界资源,即,同一时刻,只能有一个线程能够访问临界资源锁的类型:显示锁和隐式锁
隐式锁:类似于Synchronized加锁机制,JVM的内置锁,不需要手动加锁和解锁显示锁:ReentrantLock,实现JUC 里面Lock,实现是基于AQS实现,需要手动加锁和解锁。ReentrantLock的lock()和unlock()
- JVM内置锁的灵活度要低于AQS锁。JVM内置锁几乎不可能跨方法加锁,Synchronized加锁的是对象,但是AQS可以Synchronized可以理解为操作系统实现的锁,AQS是利用JAVA语言自己实现的一个锁机制
读锁:一般就是共享锁写锁:一般就是排它锁 Synchronized
- Synchronized在static方法上加锁,则相当于把锁加在了类对象上Synchronized在非static方法上加锁,相当于加锁在this当前对象上,当前bean由容器管理,必须bean的作用域是单例
!
Synchronized加锁方式- Synchronized编译生成字节码后会在同步代码的前后生成monitorenter和monitorexit。JVM内置锁通过synchronized使用,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低如果Synchronized修饰的是某个类的方法。如果不是这个类的同一个实例化对象,那么不同线程调用该方法是并行的,不是同步的。否则就是同步的。如果Synchronized对某个对象的this加锁,如果传入的是同一个对象,则是同步的,如果这个this不是同一个对象,则不是同步的。如果Synchronized对某个xxx.class加锁,表明这是一个全局锁,不管线程中传入的是否是同一个对象,都是同步的。静态方法前加Synchronized。因为静态方法可以直接用类名调用,所以就没有实例化对象,从测试来看也是同步效果。
| 加锁对象 | 锁对象 | 作用域 | 安全事项 |
|---|---|---|---|
| 类方法 | 实例对象 | 同一个对象 | 不同的实例对象可以并行 |
| this | 实例对象 | 同一个对象 | 不同的实例对象可以并行 |
| 类class | 所有该类实例对象(全局锁) | 整个类 | 全局锁 |
| 代码块 | 所有对象(全局锁) | 整个代码块 | 相当于全局锁 |
| 静态方法 | 所有对象(全局锁) | 静态方法 | 相当于全局锁 |
- 对于对象锁,如何实现跨方法的释放锁?
1、由于Synchronized属于JVM内置锁,由JVM管理加锁和释放锁。但是不支持跨方法的加锁和释放锁。
2、可以通过Unsafe魔法类实现跨方法的加锁和释放锁,详见:第一章的Unsafe类。该魔法类超越JVM,直接在内存中设置内存屏障进行加锁。
- 对象的内存结构
1、对象头:比如hash码,对象所属年代,对象锁,锁状态标识,偏向锁,ID,偏向时间,数组长度等。
2、对象实际数据:即创建对象时,对象中成员变量,方法等。
3、对齐填充:对象大小必须是8字节的整数倍
- 实例对象内存存在哪里?
1、实例对象内存存在堆区,实例对象的引用存在栈上,实例对象的元数据class存在方法区或者元空间
- 实例对象一定会存在堆区吗?
下面主要有下面几个内容1、不一定,如果实例对象没有线程逃逸行为,例如:通过for循环创建50万个同一个类的对象,此时通过开启逃逸分析,会发现堆内存里面并没有50万个堆象,原因就是对象创建时发生了逃逸现象,对象存储到了栈上。
- 锁消除锁优化对象的存储结构逃逸分析锁的膨胀升级
Mark Word内存结构
JVM开启逃逸分析- 什么是逃逸分析:逃逸分析是指源代码在通过JIT编译时,会先对编译代码进行优化,可以通过JVM参数关闭或者开启逃逸分析,逃逸分析是一种代码优化,目的是防止堆内存溢出。逃逸行为会导致线程栈中存在实例化对象。逃逸分析,默认是从jdk1.7开始的VM运行参数开启逃逸分析
开启逃逸分析
-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:HeapDumpOnOutofMemoryError
关闭逃逸分析
-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
- 查看进程
jps
- 查看堆内存
jmap -histo 进程ID
- 使用逃逸分析可以对代码做如下优化
JVM锁的优化-锁的粗化与消除一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可
以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远
不会逃逸,对象可能是栈分配的候选,而不是堆分配。
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问
到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
锁的粗化
- 例如:我们使用下面代码
public class Test{
StringBuffer str=new StringBuffer();
public void test1(){
str.append("1");
str.append("2");
str.append("3");
str.append("4");
str.append("5");
}
}
由于StringBuffer底层采用Synchronized进行同步,那么就会造成每一个str.append()进行加锁。就像下面代码一样
public class Test{
StringBuffer str=new StringBuffer();
public void test1(){
synchronized(){
str.append("1");
}
synchronized(){
str.append("2");
}
synchronized(){
str.append("3");
}
synchronized(){
str.append("4");
}
synchronized(){
str.append("5");
}
}
}
此时JVM会对该代码进行优化,把多个锁改成一个锁,称为锁的粗化
public class Test{
StringBuffer str=new StringBuffer();
public void test1(){
synchronized(){
str.append("1");
str.append("2");
str.append("3");
str.append("4");
str.append("5");
}
}
}
锁的消除
public class Test{
StringBuffer str=new StringBuffer();
public void test1(){
synchronized(new Object()){
str.append("1");
str.append("2");
str.append("3");
str.append("4");
str.append("5");
}
}
}
JVM内置锁优化升级过程Synchronized的加锁,本质是对同一个实例化对象的加锁,上面这种加锁毫无意义,JVM会进行优化,将锁释放掉
- jdk1.6之后,JVM对synchronized的实现进行了各种优化,如升级为自旋锁、偏向锁和轻量级锁
默认开启偏向锁
# 开启偏向锁 -XX:+UseBiasedLocking -XX:BiaseLockingStartupDelay=0 # 关闭偏向锁 -XX:-UseBiasedLocking
- 为什么会对锁进行优化?
1、jdk6之前,没有对锁进行优化,当服务启动后,一个用户访问后,加锁的代码会变成重量级锁,会严重降低系统的性能。
2、jdk6之后,对锁进行了优化,当服务启动后,在没有用户访问时,是不加锁的,当一个用户访问了,会增加一个偏向锁,当用户达到一定人数后,会升级为轻量级锁,当用户达到很多时,会升级为重量级锁。
整个升级过程是不可逆的。
据有人测试,开启偏向锁后,性能会提示10%左右,默认是开启的
偏向锁—总是同一个线程多次获得锁- 偏向锁是java6之后加入的锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多
次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模 式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激 烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁—出现线程间交替执行- 倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
- 轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。自旋不会丢弃CPU使用权,不同于阻塞。JVM认为线程的阻塞和唤醒代价很高。jdk7之前,需要手动设置自旋次数。jdk8会根据上一次自旋成功次数来调整自动调整自旋次数。增加了智能分析
- 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
- 定义了Sync全局变量,Sync继承了AbstractQueuedSynchronizer,该抽象类定义了state变量,独占型线程exclusiveOwnerThread内部类Node,Node可以构造条件队列也可以构造同步队列,如果Node在条件队列当中,那么Node必须是独占模式,不可能是共享的 BlockingQueueReentrantLock使用了同步队列,java里面的一种CLH队列的变种,不是让线程自旋而是阻塞ReentrantLock是基于AQS实现的锁ReentrantLock实现公平加锁的过程:
1、一个线程过来
2、先查看当前的state是否==0
3、如果等于0,再看锁队列里面是否有锁
4、进行compareAndSetState()也就是CAS进行原子操作,进行加锁,CAS底层采用了Unsafe魔法类实现同步性。Unsafe魔法类使用了贴近硬件的cmpchxg汇编指令实现的。
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
- Node添加节点的过程
1、获取当前线程,从而创建Node节点。
2、获取队列的尾节点
3、如果尾节点不为空,就将当前节点插入队列的尾部。插入过程采用了CAS原子操作。如果插入失败,就会使用死循环不断的插入
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
- 当只有一个节点是,Node节点的head和tail指针指向同一个节点。
- 独占可重入锁可以实现公平和非公平锁采用CLH实现阻塞队列CLH队列使用Doug Lea的Node节点,第一个Node节点的head,pre为null,next为后继节点。Node在条件队列当中,Node必须是独占模式ExclusiveAQS锁,唤醒队列时,只会唤醒第一个阻塞的节点,顺序的唤醒,而Object类的notify和notifyAll会唤醒队列中的所有节点AQS中,通过Unsafe魔术类中的park()和unpark()方法来阻塞线程。底层是通过调用操作系统的Pthread_mutex_lock来阻塞线程
- 生平不识Doug Lea,学懂并发也枉然Java并发编程核心在于java.concurrent.util包而juc当中的大多数同步器实现都是围绕着共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,而这个行为的抽象就是基于AbstractQueuedSynchronizer简称AQS,AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state)的同步器。AQS具备特性–也就是可以实现的锁
阻塞等待队列共享、独占公平、非公平可重入允许中断
- java.concurrent.util当中同步器的实现如Lock、Latch、Barrier等都是基于AQS框架实现。
一般通过定义内部类Sync继承AQS将同步器所有调用都映射到Sync对应的方法上
- AQS内部维护属性volatile int state (32位)
state表示资源的可用状态,用于记录上锁次数state的三种访问方式:getState()、setState()、compareAndSetState()
- AQS定义两种资源共享方式
Exclusive-独占,只有一个线程能执行,如:ReentrantLockShare-共享,多个线程可以同时执行,如:Semaphore/CountDownLatch
- AQS定义两种队列
同步等待队列条件等待队列不管是条件队列还是同步等待队列,都是基于Node类实现的 同步等待队列
- CLH队列是Craig、Landin、Hagersten三人发明的一种基于双向链表数据结构的队列,是FIFO先入先出线程等待队列,Java中的CLH队列是原CLH队列的一个变种,线程由原自旋机制改为阻塞机制。
- 不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
- 读锁(独享锁、排它锁):是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得写锁的线程即能读数据又能修改数据。写锁(共享锁):是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得读锁的线程只能读数据,不能修改数据。AQS中state字段(int类型,32位),此处state上分别描述读锁和写锁的数量于是将state变量“按位切割”切分成了两个部分
!
总结: Synchronized是隐式锁,- 可以理解为是JVM根据操作系统特性实现的锁,我们不需要去管理,JVM会帮我们解除锁等操作。Synchronized主要有下面几个点
基于AQS的显示锁1、Synchronized的加锁方式
2、Synchronized的加锁后的锁的膨胀升级过程:无锁–>偏向锁–>轻量级锁–自旋锁–>重量级锁
- 可以实现公平锁、非公平锁可重入锁、不可重入锁拥有自己把控的阻塞队列允许中断锁机制可以实现读写锁
- Synchronized的加锁方式:https://blog.csdn.net/oman001/article/details/105059069



