一、Synchronized
1.1、Synchronized有如下3种使用方式
- 普通同步方法,锁是当前实例对象
- 静态同步方法,锁是当前类的class对象
- 同步方法块,锁是括号里面的对象
当一个线程访问同步代码块时,需要获得锁才能执行,当退出或者抛出异常的时候要释放锁。那么是怎么实现的呢,我们来看一段代码
public class synchronizedTest {
public synchronized void test1(){
}
public void test2(){
synchronized (this){
}
}
}
1.2、synchronized实现
我们用javap来分析一下编译后的class文件,看看synchronized如何实现的
从这个截图上可以看出,同步代码块是使用monitorenter和monitorexit指令实现,monitorenter插入到代码块开始的地方,monitorexit插入到代码块结束的地方,当monitor被持有后,就处于锁定状态,也就是上锁了
synchronized实现锁的两个重要概念:Java对象头和monitor
1.3、Java对象头
synchronized的锁是存在对象头里的,对象头由两部分数据组成:Mark Word(标记字段)、Klass Pointer(类型指针)
Mark Word存储了对象自身运行时数据,如hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID等等。是实现轻量级锁和偏向锁的关键,Klass Pointer是lava对象指向类元数据的指针,jvm通过这个指针确定这个对象是哪个类的实例
1.4、monitor
每个Java对象从娘胎里出来就带着一把看不见的锁,叫做内部锁或者monitor锁,我们可以把它理解成一种同步机制,它是线程私有的数据结构,
monitor的结构如下:
- Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
- EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
- RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
- Nest:用来实现重入锁的计数。
- HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GCage)。
- Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。
二、锁优化篇
JDK1.6引入了大量的优化,如:自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁。锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。但是有一点,不可以进行锁降级(很难进行降级,可忽略不计)
2.1、自旋锁:
线程频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,会给系统带来很大的压力。同时很多锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁,如果释放了,就可以抢到锁。那怎么等待呢?其实就是执行一段无意义的循环。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。但是如果自旋很久都没抢到锁,那自旋就是浪费资源,说的难听点就是占着茅坑不拉屎。所以说,自旋等待的时间或者次数必须要有一个限度,如果超过了定义的时间仍然没有获取到锁,则把它挂起。
自旋锁在JDK1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;但是无论你怎么调整这些参数,都无法满足不可预知的情况。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
2.2、适应自旋锁
JDK1.6引入了更加聪明的自旋锁,叫做自适应自旋锁。他的自旋次数是会变的,我用大白话来讲一下,就是线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。大
2.3、锁消除
锁消除用大白话来讲,就是在一段程序里你用了锁,但是jvm检测到这段程序里不存在共享数据竞争问题,也就是变量没有逃逸出方法外,这个时候jvm就会把这个锁消除掉
我们程序员写代码的时候自然是知道哪里需要上锁,哪里不需要,但是有时候我们虽然没有显示使用锁,但是我们不小心使了一些线程安全的API时,如StringBuffer、Vector、HashTable等,这个时候会隐形的加锁。比如下段代码
public void sbTest(){
StringBuffer sb = new StringBuffer();
for(int i = 0; i <10; i++){
sb.append(i);
}
System.out.pringln(sb.toString);
}
上面这段代码,JVM可以明显检测到变量sb没有逃逸出方法sbTest()之外,所以JVM可以大胆地将sbTest内部的加锁操作消除。
2.4、锁粗化
众所周知在使用锁的时候,要让锁的作用范围尽量的小,这样是为了在锁内执行代码尽可能少,缩短持有锁的时间,其他等待锁的线程能尽快拿到锁。在大多数的情况下这样做是正确的。但是连续加锁解锁操作,可能会导致不必要的性能损耗,比如下面这个for循环:
锁粗化前:
for (...){
synchronized (obj) {
//一些操作
}
}
锁租化后:
synchronized (this) {
for (...){
//一些操作
}
}
应该能看出锁粗化大概是什么意思了。就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。即加锁解锁操作会移到for循环之外。
2.5、偏向锁
当我们创建一个对象时,该对象的部分Markword关键数据如下。
bit fields | 是否偏向锁 | 锁标志位 |
---|---|---|
hash | 0 | 01 |
可以看出,偏向锁的标志位是“01”,状态是“0”,表示该对象还没有被加上偏向锁。(“1”是表示被加上偏向锁)。该对象被创建出来的那一刻,就有了偏向锁的标志位,这也说明了所有对象都是可偏向的,但所有对象的状态都为“0”,也同时说明所有被创建的对象的偏向锁并没有生效。
不过,当线程执行到临界区(critical section)时,此时会利用CAS(Compare and Swap)操作,将线程ID插入到Markword中,同时修改偏向锁的标志位。|
所调临界区,就是只允许一个线程进去执行操作的区域,即同步代码块。CAS是一个原子性操作此时的Mark word的结构信息如下:
bit fields | 是否偏向锁 | 锁标志位 |
---|---|---|
threadld | 1 | 01 |
此时偏向锁的状态为“1”,说明对象的偏向锁生效了,同时也可以看到,哪个线程获得了该对象的锁。
偏向锁是jdk1.6引入的一项锁优化,其中的“偏“是偏心的偏。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。也就是说:在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不再需要去进行加锁或者解锁操作,而是会做以下的步骤:|
- Load-and-test,也就是简单判断一下当前线程id是否与Markword当中的线程id是否一致.
- 如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码.
- 如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁“标志位的值。
- 如果还未偏向,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作。
偏向锁释放锁
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
撤销偏向锁,恢复到无锁状态或者轻量级锁的状态;
2.6、轻量级锁
自旋锁的目标是降低线程切换的成本。如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record(Lock Record:JVM检测到当前对象是无锁状态,则会在当前线程的栈帧中创建一个名为LOCKRECOD表空间用于copy Mark word中的数据),如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
当然,由于轻量级锁天然瞄准不存在锁竞争的场景,如果存在锁竞争但不激烈,仍然可以用自旋锁优化,自旋失败后再膨胀为重量级锁。
缺点:同自旋锁相似:如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁,那么维持轻量级锁的过程就成了浪费。|
2.7、重量级锁
轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。
当轻量级所经过锁撤销等步骤升级为重量级锁之后,它的Markword部分数据大体如下
bit fields | 锁标志位 |
---|---|
指向Mutex的指针 | 10 |
为什么说重量级锁开销大呢
主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
互斥锁(重量级锁)也称为阻塞同步、悲观锁
三、锁相关面试题
3.1、synchronized和ReentrantLock的区别
synchronized是和if、else、for、while一样的关键字,ReentrantLock是类,这是二者的本质区别。
既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:
-
ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁
-
ReentrantLock可以获取各种锁的信息
-
ReentrantLock可以灵活地实现多路通知
另外,二者的锁机制其实也是不一样的:ReentrantLock底层调用的是Unsafe的park方法加锁,synchronized操作的应该是对象头中mark word。
3.2、当一个线程进入一个对象的一个synchronized方法后,其它线程是否可进入此对象的其它方法?
其他方法前是否加了synchronized关键字,如果没加,则能。
- 如果这个方法内部调用了wait,则可以进入其他synchronized方法。
- ·如果其他个方法都加了synchronized关键字,并且内部没有调用wait,则不能。
- ·如果其他方法是static,它用的同步锁是当前类的字节码,与非静态的方法不能同步,因为非静态的方法用的是this。
3.3、synchronized和java.util.concurrent.locks.Lock的异同?
主要相同点:Lock能完成synchronized所实现的所有功能。
I
主要不同点:Lock有比synchronized更精确的线程语义和更好的性能。synchronized会自动释放锁,而Lock一定要求程序员手工释放,并且必须在finally从句中释放。Lock还有更强大的功能,例如,它的tryLock方法可以非阻塞方式去拿锁。
四、voliate
4.1、voliate三特性
- 保证可见性
- 不保证复合操作的原子性
- 静止指令重排
4.1.1、可见性
JMM的内存模型
我们定义的共享变量就是存在主内存中,每个线程内的变量是在工作内存中操作的,当一个线程A修改了主内存里的一个共享变量,这个时候线程B是不知道这个值已经修改了,因为线程之间的工作内存是互相不可见的。
那么这个时候voliate的作用就是让A、B线程可以互相感知到对方对共享变量的修改,当线程A更新了共享数据,会将数据刷回到主内存中,而线程B每次去读共享数据时去主内存中读取,这样就保证了线程之间的可见性。
这种保证内存可见性的机制是:内存屏障(memory barrier)
内存屏障分为两种:Load Barrier和Store Barrier即读屏障和写屏障。
内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。I
4.1.2、不保证复合操作的原子性
-
什么叫原子性?
所调原子性,就是说一个操作不可被分割或加塞,要么全部执行,要么全不执行。1=0; ---1 j=i; ---2 i++; ---3 i=j+1; ---4
上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有1才是原子操作,其余均不是。
- 1--- Java中,对基本数据类型的变量和赋值操作都是原子性操作;
- 2--- 包含了两个操作:读取i,将值赋值给
- 3--- 包含了三个操作:读取值、i+1、将+1结果赋值给;
- 4--- 同三一样
Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)
4.1.3、有序性(禁止jvm对代码进行重排序)
有序性:即程序执行的顺序按照代码的先后顺序执行。
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果;
- 存在数据依赖关系的不允许重排序
4.2、synchronized 关键字和 volatile 关键字的区别
- volatile是变量修饰符;synchronized是修饰类、方法、代码段。
- volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
五、AQS
5.1、AQS介绍
全称:AbstractQueuedSynchronizer,是DK提供的一个同步框架,内部维护着FIFO双向队列,即CLH同步队列。
AQS依赖它来完成同步状态的管理(voliate修饰的state,用于标志是否持有锁)。
如果获取同步状态state失败时,会将当前线程及等待信息等构建成一个Node,将Node放到FIFO队列里,同时阻塞当前线程,当线程将同步状态state释放时,会把FIFO队列中的首节的唤醒,使其获取同步状态state.
很多JUC包下的锁都是基于AQS实现的
5.2、独占式同步状态过程
在AQS中维护着一个上面的FIFO的同步队列,当线程获取同步状态失败后,则会加入到这个CLH同步队列的对尾并一直保持着自旋。
在CLH同步队列中的线程在自旋时会判断其前驱节点是否为首节点,如果为首节点则不断尝试获取同步状态state,获取成功则退出CLH同步队列。当线程执行完逻辑后,会释放同步状态state,释放后会唤醒其后继节点。
tryAcquire方法尝试去获取锁,获取成功返回true,否则返回false。该方法由继承AQS的子类自己实现。采用了模板方法设计模式。
5.3、AQS定义两种资源共享方式
1、独占(Exclusive):只有一个线程能执行,其原理是看哪个线程先把state+1,谁就抢到了锁,如ReentrantLock。又可分为公平锁和非公平锁:
-
公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
-
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的,所以非公平锁效率较高
2、共享(Share):多个线程可同时执行,其原理就是多个线程去操作state字段,来一个线程就+1,来一个线程就+1
线程运行结束后state-1,一直减到0,就释放锁了。如Semaphore、CountDownLatch。
六、ThreadLocal
很多人认为ThreadLocal是多线程同步机制的一种,其实不然,他是为多线程环境下为变量线程安全提供的一种解决思路,他是解决多线程下成员变量的安全问题,不是解决多线程下共享变量的安全问题。
线程同步机制是多个线程共享一个变量,而ThreadLocal是每个线程创建一个自己的单独变量副本,所以每个线程都可以独立的改变自己的变量副本。并且不会影响其他线程的变量副本。
6.1、ThreadLocalMap
ThreadLocal内部有一个非常重要的内部类:ThreadLocalMap,该类才是真正实现线程隔离机制的关键,ThreadLocalMap内部结构类似于map,由键值对key和value组成一个Entry,key为ThreadLocal本身,value是对应的线程变量副本
注意:
1、ThreadLocal本身不存储值,他只是提供一个查找到值的key给你。
2、ThreadLocal包含在Thread中,不是Thread包含在ThreadLocal中。
**ThreadLocalMap和HashMap的功能类似,但是实现上却有很大的不同:**HashMap的数据结构是数组+链表
- ThreadLocalMap的数据结构仅仅是数组
- HashMap是通过链地址法解决hash冲突的问题
- ThreadLocalMap是通过开放地址法来解决hash冲突的问题
- HashMap里面的Entry内部类的引用都是强引用
- ThreadLocalMap里面的Entry内部类中的key是弱引用,value是强引用
6.2、Thread、ThreadLocal、ThreadLocalMap之间的关系
6.3、内存泄露问题
//部分源码
static class Entry extends weakReference<ThreadLocal <?>> {
/** The value associated with this ThreadLocal.*/
object value;
Entry (ThreadLocal < ? > k , object v ) {
super(k);
value = V;
}
}
从上面源码可以看出ThreadLocalMap中使用的key为ThreadLocal的弱引用,而value是强引用。
所以,如果ThreadLocal没有被外部强引用的情况下,在垃圾回收的时候会key会被清理掉,而value不会被清理掉。
这样一来,ThreadLocalMap中就会出现key为null的Entry。假如我们不做任何措施的话,value永远无法被GC回收,这个时候就可能会产生内存泄露。
我们上面介绍的get、set、remove等方法中,都会对key为null的Entry进行清除(expungeStaleEntry方法,将Entry的value清空,等下一次垃圾回收时,这些Entry将会被彻底回收)。
如何避免内存泄漏:
为了避免这种情况,我们可以在使用完ThreadLocal后,手动调用remove方法,以避免出现内存泄漏。
七、CAS
7.1、CAS介绍
CAS的全称是Compare-And-Swap,它是一条CPU并发原语。
正如它的名字一样,比较并交换,它是一种很重要的同步思想。如果主内存的值跟期望值一样,那么就进行修改,否则一直重试,直到一致为止。
而原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致性问题。
它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。
7.2、ABA问题
所谓ABA问题,其实用最通俗易懂的话语来总结就是狸猫换太子
就是比较并交换的循环,存在一个时间差,而这个时间差可能带来意想不到的问题。
比如有两个线程A、B:
- 一开始都从主内存中拷贝了原值为3;
- A线程执行到var5=this.getlntVolatile,即var5=3。此时A线程挂起;
- B修改原值为4,B线程执行完毕;
- 然后B觉得修改错了,然后再重新把值修改为3;
- A线程被唤醒,执行this.compareAndSwaplnt()方法,发现这个时候主内存的值等于快照值3,(但是却不知道B曾经修改过),修改成功。
尽管线程ACAS操作成功,但不代表就没有问题。有的需求,比如CAS,只注重头和尾,只要首尾一致就接受。但是有的需求,还看重过程,中间不能发生任何修改。这就引出了AtomicReference原子引用。
7.3、CAS总结
任何技术都不是完美的,当然,CAS也有他的缺点:
CAS实际上是一种自旋锁,
- 一直循环,开销比较大。
- 只能保证一个变量的原子操作,多个变量依然要加锁。
- 引出了ABA问题(AtomicStampedReference可解决)。而他的使用场景适合在一些并发量不高、线程竞争较少的情况,加锁太重。但是一旦线程冲突严重的情况下,循环时间太长,为给CPU带来很大的开销。
评论区