Synchronized底层实现原理
Java对象头+Monitor
- Synchronized 通过在对象头中(MarkWord)设置标记实现了这一目的,是由 JVM 实现的一种实现互斥同步的一种方式,JVM基于进入和退出Monitor对象来实现方法同步和代码同步,被 Synchronized 修饰过的程序块,在编译前后被编译器生成了 monitorenter 和 monitorexit 两个字节码指令。
- 在虚拟机执行到 monitorenter 指令时,首先要尝试获取对象的锁,如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器 +1;当执行 monitorexit 指令时将锁计数器 -1;当计数器为 0 时,锁就被释放了。如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
- 执行到该代码块时,程序的运行级别从用户态切换到内核态中完成(挂起线程和恢复线程的操作),让cpu通过操作系统指令,去调度多线程之间,谁执行代码块,谁进入阻塞状态。这样会频繁出现程序运行状态的切换,这样就会大量消耗资源,程序运行的效率低下。
- 为了优化synchronized机制,从java6开始引入偏向锁,和轻量级锁,尽量让多线程访问公共资源的时候,不进行程序运行状态的切换。
锁的四种状态
根据重量级由低到高的次序,随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁(但是锁的升级是单向的,也就是说只能从低级到高级,不会出现锁降级)。
1,无锁状态:
2,偏向锁状态
Why:加锁和解锁不需要额外的消耗,消除了同步,提高性能;
What:本质是指向互斥量的指针,锁标志位设为01,一个线程持有锁,其他竞争时,再释放;
Where:只有一个线程进入临界区
缺点:线程间存在锁竞争时,有额外的锁撤销消耗。
3,轻量级锁状态
Why:竞争的线程不会阻塞,提高程序响应速度;
What:指向栈中锁记录的指针,锁标志位设为00,线程a和b交替进入临界区;
Where:多个线程交替进入临界区,追求响应时间,同步快执行速度非常快;
缺点:得不到锁竞争的线程使用自旋消耗cpu。
4,重量级锁状态
Why:线程竞争不适用自旋,不消耗cpu;
What:synchronized;
Where:多个线程进入临界区,追求吞吐量,同步快执行速度比较长;
缺点:线程阻塞,响应时间缓慢。
对比:
偏向锁通过对比 Mark Word 解决加锁问题,避免执行CAS操作。
轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
重量级锁是将除了拥有锁的线程以外的线程都阻塞。
三种锁状态详解
偏向锁:偏向锁的获取方式是将对象头的 MarkWord 部分中,标记上线程ID。
- 首先读取目标对象的markword,判断是否处于可偏向状态(可偏向标志位位1,对象头末尾标志为01)
- 如果是可偏向状态,则尝试用CAS操作,将自己线程ID写入markword。若cas成功,则认为已经获取到该对象的偏向锁,执行同步块代码,并且在执行完同步代码块以后, 并不会尝试将 MarkWord 中的 thread ID 赋回原值。若cas失败,则说明, 有另外一个线程 Thread B 抢先获取了偏向锁。 这种状态说明该对象的竞争比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。
- 如果是已偏向状态,则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID。若想等,则证明本线程已经获取到偏向锁,可以直接继续执行同步代码块。若不等,则证明该对象目前偏向于其他线程,需要撤销偏向锁。
- 偏向锁的撤销:在偏向锁的获取过程中,发现了竞争时,直接将一个被偏向的对象“升级到”被加了轻量级锁的状态。操作过程:偏向锁cas更新操作失败以后, 等待到达全局安全点。通过 MarkWord 中已经存在的 Thread Id 找到成功获取了偏向锁的那个线程, 然后在该线程的栈帧中补充上轻量级加锁时, 会保存的锁记录(Lock Record), 然后将被获取了偏向锁对象的 MarkWord 更新为指向这条锁记录的指针。
轻量级锁:
- 轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能
- 轻量级锁的获取主要由两种情况:① 当关闭偏向锁功能时;② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态,虚拟机将首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后将对象头中的 Mark Word 复制到锁记录中。拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
- 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
- 若当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定的次数时,轻量级锁便会升级为重量级锁(锁膨胀)。另外,当一个线程已持有锁,另一个线程在自旋,而此时又有第三个线程来访时,轻量级锁也会升级为重量级锁(锁膨胀)。
重量级锁
- 重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
- 重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
- 简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源,导致性能低下。
Synchronized锁对象
Java中的每一个对象都可以作为锁
1.对于普通同步方法,锁是当前实例对象
2.对于静态同步方法,锁是当前类的Class对象(在程序运行期间,Java运行时系统始终为所有的对象维护一个被称为运行时的类型标识,这些信息的类称为Class)
3.对于同步方法块,锁是Synchronized括号里配置的对象
当一个线程驶入访问同步块时,它首先必须得到锁。退出或者抛出异常时必须释放锁。
当一个线程A进入一个对象的一个synchronized方法A后,其他线程B是否可以进此对象的其他方法B?
1) 如果方法B前加了synchronized关键字,就不能,如果没加synchronized,则能够进去。
2) 如果方法A内部调用了wait(),则可以进入其他加synchronized的方法。
3) 如果方法B加了synchronized关键字,并且没有调用wai方法,则不能。
为什么说 Synchronized 是可重入锁
可重入性是锁的一个基本要求,是为了解决自己锁死自己的情况。在执行 monitorenter 指令时,如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁(而不是已拥有了锁则不能继续获取),就把锁的计数器 +1,其实本质上就通过这种方式实现了可重入性。
Synchronized锁优化技术(是什么,为什么)
1)自旋锁(上下文切换代价大)与自适应锁:互斥锁 -> 阻塞 –> 释放CPU,线程上下文切换代价较大(挂起和恢复线程要转入内核态完成) + 共享变量的锁定时间较短 == 让线程通过自旋(忙循环)等一会儿 ;
缺点:自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的, 所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,带来性能的浪费。
自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
2)锁粗化(一个大锁优于若干小锁):一系列连续操作对同一对象的反复频繁加锁/解锁会导致不必要的性能损耗,建议粗化锁。一般而言,同步范围越小越好,这样便于其他线程尽快拿到锁,但仍然存在特例。
3)偏向锁(有锁但当前情形不存在竞争):消除数据在无竞争情况下的同步原语,提高带有同步但无竞争的程序性能。锁偏向于第一个获得它的线程。
4)锁消除(有锁但不存在竞争,锁多余):JVM编译优化,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。
锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待, 认为它们是线程私有的,同步加锁自然就无须进行。
Synchronized 是非公平锁
非公平主要表现在获取锁的行为上,并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象。
为什么说 Synchronized 是一个悲观锁?ReetrantLock乐观锁?
1)不管是否会产生竞争,任何的数据操作都必须要加锁;其他线程只能依靠阻塞来等待线程释放锁,也称未阻塞同步。
2)乐观锁的核心算法是 CAS,先进性操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据被争用,产生了冲突,那就再进行其他的补偿措施(最常见的补偿措施就是不断地重试,直到试成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步被称为非阻塞同步。
CAS
CAS,Compare and Swap即比较并交换,设计并发算法时常用到的一种技术。CAS有3个操作数,内存值V,旧的预期值A,新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS是通过unsafe类的compareAndSwap (JNI, Java Native Interface) 方法实现的,该方法包括四个参数:第一个参数是要修改的对象,第二个参数是对象中要修改变量的偏移量,第三个参数是修改之前的值,第四个参数是预想修改后的值。
CAS仍然存在三大问题:
ABA问题:ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一。
不适用于竞争激烈的情形中::并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。
只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。
ReentrantLock 底层实现原理
ReentrantLock 以及所有的基于 Lock 接口的实现类,都是通过用一个 volitile 修饰的 int 型变量,并保证每个线程都能拥有对该 int 的可见性和原子修改,其本质是基于所谓的 AQS 框架。
先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入CLH队列并且被挂起。当锁被释放之后,排在CLH队列队首的线程会被唤醒,然后CAS再次尝试获取锁。
AQS框架
- AQS = CLH(FIFO双向队列) + volatile state + CAS。AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
- 定义:AQS是AbstractQueuedSynchronizer的简称。是JDK下提供的一套用于实现基于FIFO等待队列的阻塞锁和相关的同步器的一个同步框架。这个抽象类被设计为作为一些可用原子int值来表示状态的同步器的基类,并提供了一系列的CAS操作来管理这个同步状态。例如ReentrantLock,CountdowLatch就是基于AQS实现的。
- 使用:同步队列是AQS很重要的组成部分,它是一个双端队列,是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。遵循FIFO原则,主要作用是用来存放在锁上阻塞的线程,当一个线程尝试获取锁时,如果已经被占用,那么当前线程就会被构造成一个Node节点假如到同步队列的尾部,队列的头节点是成功获取锁的节点,当头节点线程释放锁时,会唤醒后面的节点并释放当前头节点的引用。
- 对state操作的方法:final int getState(), final void setState(int newState), final Boolean compareAndSetState(int expect, int update)
- AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
- 实现独占锁要重写的方法:
protected Boolean tryRelease(int arg) // 释放锁
Protected Boolean tryAcquire(int arg) //以独占方式获取获取同步状态
protected Boolean isHeldExclusively() //是否在独占模式下被线程占用,true为被占用。
7,实现共享锁要重写的方法:
Protected int tryAcquireShared(int arg) //返回值大于0则是成功占用锁,小于0则是阻塞
Protected Boolean tryReleaseShared(int arg) //释放锁方法,返回true代表成功释放
对比下 Synchronized 和 Lock 的异同
相同点:
独占锁、可重入(Synchronized由jvm实现可重入,ReentrantLock基于AQS实现)
不同点:
1)Lock是一个接口(ReentrantLock是lock接口的一个实现类),而synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
5)Lock可以提高多个线程进行读操作的效率,既就是实现读写锁等。
5)ReentrantLock提供公平锁(等待时间最长的线程将获得锁的使用权)和非公平锁两种模式。
6)ReentrantLock通过Condition可以绑定多个条件。
7)底层实现不一样, synchronized 是同步阻塞,使用的是悲观并发策略,lock 是同步非阻塞,采用的是乐观并发策略
Lock的几种方法
void lock(); 在等待获取锁的过程中休眠并禁止一切线程调度
void lockInterruptibly() throws InterruptedException; 在等待获取锁的过程中可被中断
boolean tryLock(); 获取到锁并返回true;获取不到并返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; 在指定时间内等待获取锁;过程中可被中断
Volatile和Synchronized
- 粒度不同,前者针对变量 ,后者锁对象和类
- Volatile不需要加锁,比Synchronized更轻量级,并不会阻塞线程(volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
- syn保证三大特性(原子性,可见性,顺序性),volatile不保证原子性
- volatile标记的变量不会被编译器优化,而synchronized标记的变量可以被编译器优化(如编译器重排序的优化)
volatile关键字在Java中有什么作用
volatile用于多线程环境下的单次操作(单次读或者单次写)。volatile关键字不能提供原子性,不具备互斥性。volatile关键字为实例域的同步访问提供了一种免锁机制。
1) volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,即保证了内存的可见性,除此之外还能 禁止指令重排序。此外,synchronized关键字也可以保证内存可见性。
2) 指令重排序问题在并发环境下会导致线程安全问题,volatile关键字通过禁止指令重排序来避免这一问题。而对于Synchronized关键字,其所控制范围内的程序在执行时独占的,指令重排序问题不会对其产生任何影响,因此无论如何,其都可以保证最终的正确性。
不具有原子性的体现:自增自减操作。
i++操作可以被拆分为三步:
- 线程读取i的值
- 计算i+1(没有刷新到内存,此时阻塞的时候)
- 将i+1之后的值写回内存
一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作也就是i++,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。
Volatile修饰的变量i=100,线程A,B对其进行自增操作,都进行到第二步时,线程A阻塞,B将i=101刷新回内存并通知其他线程保存的i值失效,A唤醒重新执行,但是A已经不需要再从内存中读取i值了,所以无效,A继续将i=101写回内存。
Volatile与原子变量的区别
Volatile只保证了可见性,原子变量既保证了可见性又保证了原子性。
原子变量:
1,变量都是volatile修饰的,保证了内存可见性
2,CAS操作保证数据变量的原子性:依赖当前值得原子修改。
java.util.concurrent.atomic 包下提供了一些原子变量以及对应的数组。
Volatile的实现原理
Lock前缀指令(只实现了立即可见型)、内存屏障(分为load barrier和store barrier)
没有实现原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
可见性:
当变量被volatile修饰,编译成汇编时,会增加一个Lock前缀指令。作用:
- 写一个volatile时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存;
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效(嗅探技术),线程接下来从主内存中读取共享变量。
禁止指令重排序
内存屏障是一组CPU处理指令,用来实现对内存操作的顺序限制。内存屏障之后的指令不能越过内存屏障。
如何保证该线程对应的本地内存置为无效的
处理器使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存在总线上保持一致。
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态, 当处理器对这个数据进行修改操作的时候,会重新从系统内存中吧数据读到处理器缓存行里。
为什么指令重排序
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:
1)编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
什么是死锁(Deadlock)?如何分析和避免死锁?
死锁是指两个以上的线程永远阻塞的情况,这种情况产生至少需要两个以上的线程和两个以上的资源。
分析死锁,我们需要查看Java应用程序的线程转储。我们需要找出那些状态为BLOCKED的线程和他们等待的资源。每个资源都有一个唯一的id,用这个id我们可以找出哪些线程已经拥有了它的对象锁。下面列举了一些JDK自带的死锁检测工具:
Jconsole:JDK自带的图形化界面工具,主要用于对 Java 应用程序做性能分析和调优。
Jstack:JDK自带的命令行工具,主要用于线程Dump分析。
VisualVM:JDK自带的图形化界面工具,主要用于对 Java 应用程序做性能分析和调优。