经典面试题回顾
基础原理回顾
CAS 原理
简单看下jdk中一个cas原理的运用 : unsafe.class
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
compareAndSwapInt 就是cas 比较和交换
面试回答cas:
什么是CAS:
CAS:compare and swap 多线程访问,在没有加锁的情况下,保证线程一致性的去改变某个值,比如有个变量初始值0,第一个线程读取过来,想加1,在把1往回写的时候需要先去读最新的值,看看还是不是0,如果是,则把值改成1,如果原来的值已经被别的线程动了,改成2了,那么此时cas失败,值还是2。此时第一个线程虽然cas失败了,但是并不会被挂起,而是自旋,他会把最新的2读取,然后+1,再把3写回去的时候依然去判断原来的值是否被别的线程改变,如果改变了继续重复上述步骤。
自旋锁的优缺点
优点
-
尽可能的减少线程的阻塞,对于锁竞争不激烈,并且占用锁时间较短的代码来说,适合用自旋锁,因为他的开销相比重量级锁比如synchronized的线程wait和唤醒来说,消耗比较小,重量级锁在睡眠和唤醒需要切换两次上下文。
因为cas同时只能有一个线程获取到锁,其他都会失败,失败了就自旋,自旋是不会释放cpu资源的,这样就会有大量线程占用cpu资源,进而会影响整体系统的性能,所以自旋周期的选择就比较重要了,Jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁
自适应自旋锁
- 由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。
- 如果有半数cpu数个线程正在自旋,则后来线程直接阻塞
缺点
- 容易造成cpu资源浪费
用的是汇编指令:lock 是保证原子性的,
也就防止在写回去比较的过程中别的线程再次把值修改掉!
lock cmpxchg=cas修改的变量值
再往硬件层面走就是lock锁定了北桥芯片的一个电信号,这就没啥意义了
CAS里面的ABA问题如何解决?
什么是ABA问题
比如,第一个线程把0改为1准备往回写的时候,去读取原来的值,这个值已经被另外一个线程读取过去先改为2然后再改为0放回去,那么此时第一个线程去判断的0其实已经不是原来的那个0了
如何解决ABA问题?
很简单,在原来那个0上加一个版本号,任何值的改动都需要更新版本号,当比较的时候除了比较值,还要比较版本号,jdk1.5之后,引入AtomicStampedReference类来解决ABA问题。
这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用CAS设置为新的值和标志。
如何手写一个自旋锁?
备注:jvm只是一个协议,具体的实现有我很多:
描述一下对象在内存中的布局
new 一个object对象,main方法就是一个栈针,里面有一个object对象的引用,指向堆内存中的object对象
java object layout
-
pom依赖
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.9</version> </dependency>
-
业务代码
public static void main(String[] args) { int size = 10; List<Integer> list = new ArrayList<Integer>(size); for (int i = 0; i < size; i++) { list.add(i); } //打印实例内部的占用 System.out.println(ClassLayout.parseInstance(list).toPrintable()); }
输出结果:offset起始位
对象头
对象头用于存储对象的元数据信息
分为两块:1. 存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁等。
2. *类型指针*,指向它的类元数据的指针,用于判断对象属于哪个类的实例,比如person类,对象头就存储着这个对象的类型的指针,另外,如果对像是一个数组,那在对象头中还必须有一块用于记录<u>数组长度</u> 的数据
实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义各种类型的字段内容
对齐填充
对齐填充并不是必然存在的,由于hotspot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
拿一个空的object对象来说:
一个空的object对象,占多少字节?
-
开启classPoint的压缩情况下:
因为java jvm本身开启了classPoint的压缩,总的占16个字节,
markWord 8个字节
jvm 在64位操作系统中占8个字节,默认开启了classpoint的压缩,压缩完占4个字节。
8+4=12不能被整除,则补位对齐4个字节,总的是16字节 。
-
如果没有开启压缩其实也是占16个字节
markWord 8个字节
classPoint没有压缩 8个字节
没有成员变量 0字节
右对齐吗,没有,两个因为16已经可以被整除了,所以总的也是占16个字节
拓展:USER对象,有两个属性,在内存中占多少字节?
User user = new User();
user.name="hdx";
user.age=16;
//打印实例内部的占用
System.out.println(ClassLayout.parseInstance(user).toPrintable());
印证一下对象上的锁,锁的具体位置
在对象的markword上面
一个对象经过一个GC之后分代计数就会增加1,当大于当前垃圾回收器的阈值之后就会到老年代,cms回收器默认是6次
所以markword里面也记录了GC的信息
上面要注意锁标志位的code
锁升级的过程
*锁的种类:*无锁——>偏向锁——>轻量级锁(自旋锁)——>重量级锁
锁概念的解释:
偏向锁(01)
经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁,不加锁岂不是更好,为啥要多此一举?
比如A对象,他在获取锁的时候并没有去申请操作系统那个比较重的锁,而是把当前线程指针的Id放到markword中,作为一个标记,
偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用
偏向锁的获取过程
- 访问Mark Word中偏向锁位的标识是否设置成1,锁标志位是否为01(未锁定时的标志位),确认为可偏向状态
- 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3
- 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4
- 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)
- 执行同步代码。
安全点:
应用程序线程可以被安全地停止掉的那个时间点,就叫做安全点。这一术语也通常用来指代SWT暂停。
查看安全点日志:
要查看安全点停顿,可以打开安全点日志,通过设置JVM参数 -XX:+PrintGCApplicationStoppedTime 会打出系统停止的时间,添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 这两个参数会打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多;
注意:安全点日志不能一直打开:
如果在生产系统上要打开,再再增加下面四个参数:
-XX:+UnlockDiagnosticVMOptions -XX: -DisplayVMOutput -XX:+LogVMOutput -XX:LogFile=/dev/shm/vm.log
JVM开启和关闭安全点:
- 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
- 关闭偏向锁:-XX:-UseBiasedLocking
偏向锁的释放
只有遇到锁竞争的时候,持有偏向锁的线程才会释放锁,否则线程不会主动去释放偏向锁。偏向锁的撤销时需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态
偏向锁如何升级为轻量级锁(00)?
当偏向锁发生锁竞争的时候,偏向锁释放,然后相互竞争的线程在自己的线程栈内就会生成Lock Record 锁记录,然后每个线程之间通过自旋的方式竞争把这个锁记录写到mark Word里面,哪个线程最先修改成功 ,谁就获取到这把锁,这就是轻量级锁,也叫自旋锁。
解释下为什么需要把轻量级锁升级到重量级锁?
轻量级锁是处于用户态的,不需要向内核申请,所以比较快,但是重量级锁是放在内核态的,
因为自旋锁是很消耗cpu的,如果获取到锁的那个线程一直在执行,不释放,这时候锁是需要升级的,
升级成重量级锁的好处是什么?
所以,关键就是重量级锁的队列里面,可以是wait状态,阻塞的,不消耗cpu资源,什么时候可以执行了,再唤醒
轻量锁如何升级成为重量级锁(10)?
竞争进一步加剧:自旋超过10次:-XXPreBlockSpin,或者cpu核数的一半,jdk1.6之后有了自旋自适应,jdk自己会控制
面试回答锁升级的过程?
- new对象,此时默认是加了偏向锁,第一个访问该对象的线程会把当前线程id放到这个对象的mark word的对象头里面作为一个标记
- 出现线程竞争,则通过CAS操作竞争锁,如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,继续执行代码,竞争失败,jvm让拥有偏向锁的线程挂起,等到了安全点的时候stw,释放锁,锁升级为轻量级锁,锁标志位变为00,每个线程之间通过自旋的方式吧自己线程栈里面的lock record写入到mark word,谁修改成功谁就获取到锁——自旋锁。
- 竞争进一步加剧,出现了大量的线程在自旋,或者自旋超过一定的次数,这时候cpu空转比较厉害,为了节省cpu资源,jvm开始向操作系统申请重量级锁——synchronized、ReentrantLock,
有个上面的基础,可以解释synchronized的执行过程?
-
检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
-
如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
-
如果失败,则说明有锁竞争,撤销偏向锁,进而升级为轻量级锁。
-
当前线程使用CAS将对象头的Mark Word替换为当前线程的lock record,如果成功,当前线程获得锁 ,如果失败,则自旋不停的获取锁。
-
当自旋一定次数之后获取成功了,还是用轻量级锁,如果失败了,锁再次升级为重量级锁,之前自旋的线程进入wait状态,等待cpu分配时间片后再次执行
如果线程争用激烈,那么应该禁用偏向锁。
锁消除
总结:对于不可能共享的资源,比如局部变量,在执行的时候jvm会把对象锁消除,比如一个方法里面的stringBuffer的append()
锁粗话
总结:假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的