二本26届大三Java实习准备 Day5
2025 年 3 月 1 日 Day 5
昨天休息了一天今天恢复学习状态 JUC 原本计划三天现在看来可能需要四五天;JUC 结束后就好好背一背之前复习的内容; 然后好好看看 SpringAI 相关的内容
今天学习了 synchronized 和 LockSupport ;明天学习 AQS CAS JMM 冲冲冲!!!
介绍一下 java 中对象和对象头的结构
java 中每个对象都包括三部分: 对象头,实例数据,对齐填充;
- 实例数据就是每个对象的字段和方法, 包括继承父类的字段
- 对齐填充是为了让每个对象的大小都符合 8 byte 的倍数, jvm 的内存对齐要求
- 对象头中包含的对象的锁、垃圾回收、哈希码和类型指针等信息
对象头可以分为两部分: mark word, class pointer, 如果是数组对象的话还会有数组长度
- class pointer 就是指向方法区中当前实例的数据
- mark word 存储的是当前对象的哈希、锁、垃圾回收信息
- mark word 是非结构化的有 32 bit;
- markWord 的最后两位标志着当前对象锁的状态分别对应 00 无锁、01 偏向锁、10 轻量级锁、10 重量级锁, 11 gc 标记; 标记为的变化也就是锁升级的过程
为什么每个对象都能作为锁对象?
java 中的每个对象理论上都可以作为锁的对象; 这要从每个对象的构成上开始说明, java 中的所有对象都可以分成三部分; 对象头、实例数据、对齐填充; 而三部分中的对象头中有 markword 和 class pointer; 其中这个 class pointer 就是指向在栈中对象实例数据的指针, 而这个 mark word 就是每个对象都能作为锁对象的关键; markWord 包括锁、垃圾回收、哈希码三部分信息; 锁信息通过不同的标志为表示不同级别的锁; 也就意味着每个对象都可以作为锁并且可以根据线程的竞争状况对锁进行升级; 这就是每个对象都可以成为锁对象物理层面的原因; 再底层的原因就是每个对象都继承 Object 类; 而 Object 有个指针指向 monitor 对象; 这个 monitor 对象底层对应的是 c++的 ObjectMonitor 这个管程对象就标志着每个对象作为锁的线程信息、重入次数、被持有线程 id 等信息; monitor 对象又通过底层 monitorenter 和 monitorexit 指令来进行线程同步; 这就是实现层面每个对象都能作为锁对象的原因
介绍一下 Monitor
每个 java 对象都继承 Object 类, 而 Object 类中有个指针指向底层 c++中的 ObjectMonitor,也就是说每个 java 对象都有一个 monitor 对象进行控制, 主要是控制每个对象的锁情况; monitor 对象中的核心字段有
_EntrySet _WaitSet _woner _recursion
所有需要进行竞争的线程都会进入 EntrySet 中进行等待, 当一个线程获取到了 CPU 执行权那么就会将 woner 字段改成自己的线程 id 并且将 recursion +1, recursion 字段是实现重入锁的关键; 如果一个线程在执行的过程中通过 wait 进行挂起那么就会进行 waitSet 等待其他线程唤醒; 如果 waitSet 中的线程被唤醒就会再次进入 EntrySet 中进行竞争; 当线程执行完成之后并且 recursion 字段也为 0 那么就会被释放然后将锁交给其他 EntrySet 中的线程 JVM 层面是通同 monitorenter 和 monitorexit 两个指令来实现的
synchronized 的底层原理是什么?
在 jdk 1.6 之前 synchronized 是通过重量级锁的方式实现线程同步的, 但是重量级锁会从用户态切换到内核态, 这个操作的消耗锁很大的, 在很多场景下是不需要这样重的锁的所以在 1.6 之后进引入了无锁、偏向锁、轻量级锁中间态通过锁的升级来优化性能; 具体实现如下: 我们知道 synchronized 根据用法不同可以锁类的 class 对象, 也可以锁实例化对象; 而不管是什么对象在 java 中都继承 Object 类, 而 Object 中有个 monitor 指针指向底层 c++代码中的 ObjectMonitor 对象, 这也就意味着每个对象从物理内存层面都拥有作为锁的条件; 而有了物理条件后 monitor 通过 monitorenter 和 monitorexit 两条指令完成了代码层面的实现; 具体实现是通过 monitor 对象的核心字段比如 woner、entrySet、waitSet、recursion; 基本流程是所有需要进行锁竞争的线程会进入 EntrySet 中进行等待, 获取到权限的线程会将 woner 字段改成当前线程 id 然后将 recursion 进行+1; 执行完成后就会让出执行权给其他 EntrySet 中的线程; 如果在执行过程中通过 wait 让线程进行 waitting 状态那么就会将线程放到 WaitSet 中等待其他线程唤醒, 唤醒后又会进行 EntrySet 进行竞争
讲讲公平锁和非公平锁
公平和非公平锁的区别就是当一个线程需要进行线程竞争的时候公平锁如果发现队列中已经有其他线程正在竞争那么他会插入队尾, 并且当锁释放时会唤醒队列的投节点线程; 而非公平锁锁当线程在进入队列的顺序就尝试获取锁, 如果这时候锁刚好被释放那么这个线程就允许直接获得执行权相当于一次插队操作; 两种方式各有利弊, 对于公平锁来说他每次都需要 CPU 去唤醒对列头的线程, 每次唤醒的开销相比非公平锁会比较大, 并且维护有序队列也会增加额外开销; 而非公平锁虽然减少了 CPU 唤醒线程的开销线程切换的吞吐量也会更高但是可能出现某些线程被频繁插队导致线程饥饿的情况, 而公平锁就不会有线程饥饿的情况;
公平锁的使用场景:
- 对有序性要求高比如订单和金融系统
- 不能容忍线程饥饿
- 持锁时间长
非公平锁的使用场景:
- 吞吐量优先
- 持锁时间短
- 可以容忍一定程度的线程饥饿
- 对有序性要求不严格
sychronized 是非公平锁吗,那么是如何体现的?
synchronized 是非公平锁; 主要的原因还是出于性能考虑; JVM 会从 EntrySet 随机唤醒一个线程, 这相较于公平锁维护一个队列并且每次唤醒然后切换线程的开销来说更小; 虽然非公平锁会引发线程饥饿的情况, 但是大多数情况下一个锁不会被长时间持有, 并且 synchronized 锁的升级过程在操作系统调度层面已经减少了相当于部分线程饥饿的情况; 如果某些情况确实十分需要有序性那么可以通过 ReenrantLock 实现公平锁
synchronized 的锁升级过程是怎样的?
在 jdk 1.6 之前 sychronized 是通过重量级锁来实现同步的; 但是这种方法因为权限隔离的缘故会从用户态切换到线程态, 这会带来很大的性能消耗; 所有在 1.6 之后引入了偏向锁和轻量级锁这两个中间态通过锁的升级来平衡性能和线程安全之间的效率; 锁的升级过程是通过主要体现在对象头中 markword 的变化体现的, 底层则是通过 monitor 对象和 monitorenter monitorexit 两个指令进行控制; 锁的具体升级过程如下:
- 当一个对象被创建出来后在被线程获取锁对象之前锁状态处于无锁
- 当有线程获取锁发现锁无锁, 那么就会将锁状态改为偏向锁, 偏向的线程 id 则是当前线程; 如果下次线程获取锁的时候发现锁的状态锁偏向锁并且偏向的 id 为当前线程那么就会直接获取锁;偏向锁的出现主要是在许多场景下只有单一线程在进行操作, 如果每次都进行繁琐的加锁释放操作那么性能就会很低; 避免单一线程场景下锁的性能浪费
- 如果在多线程竞争锁的环境下发现锁为偏向锁并且持有锁的线程 id 不是当前线程 id 那么就会将锁升级为轻量级锁; 轻量级锁会让所以等待的线程进行自旋等待, 所以又称为自旋锁; 自旋等待的线程不会挂起等待, 这样降低了线程挂起和切换恢复现场的开销, 但是归根到底自旋都是 CPU 在处于空转
- 如果大量的空转那么性能浪费就会很大所以在自旋次数达到一定情况后就会升级为重量级锁, 这个自旋次数在早期锁可以通过 jvm 调优指定的但是后续变成了自适应; 升级为重量级锁后就要个通过 monitor 的控制流程进行资源锁定和线程竞争; 还有一种情况就是在轻量级锁的状态下调用 wait 方法那么就会触发锁膨胀, 膨胀为重量级锁
轻量级锁:
- 在偏向锁状态下锁通过 markword 中查看锁的偏向线程 id 来进行标记的; 但是在轻量级锁中线程会在自己的栈中间中开辟一块空间 LockRecord, 包含两部分一部分用于获取锁成功后保存 markword 的副本, 另一部分则是一个指向锁的指针, 而在 markword 中也会见前 30 个 bit 指向这个 lock record 指针这样一来就实现了线程和锁的双向绑定, 也确保了在线程释放锁的时候就还原锁的副本信息; 而这些信息会在升级重量级锁后被放在 monitor 对象中也同样在释放时恢复
锁升级后 HashCode, GC 年龄这些信息去哪里了?
hashCode 和偏向锁锁互斥的; 也就锁一个调用过 hashCode 的锁就无法升级为偏向锁会直接升级为轻量级, 同理一个偏向锁如果调用了 hashCode 方法那么就会根据线程竞争情况膨胀成轻量级锁和重量级锁; hashCode 和轻量级锁是可以共存的, 轻量级锁会在竞争锁的时候将 markword 进行备份放在栈中, 并且在释放锁的时候将这些信息进行恢复; 而重量级锁则会将信息保存在 monitor 对象中;
聊一聊并发编程三大特性
原子性、可见性、有序性 原子性:
- 并发编程中的原子性意思是一组指令在进行执行的过程中不能被中断也不能被抢占; (数据库中的原子性指的是一组操作同时执行成功或者同时执行失败然后回滚) 可见性:
- 指的是一个线程在操作多线程共享资源时对资源的修改操作能立刻让其他线程感知到 有序性
- 有序性主要针对的是指令重排序; 什么是指令重排序呢? 重排序就指在单线程下以不影响代码执行效果为前提下通过对指令的顺序交换来提升代码的执行效率; 重排序会发生在多个层面, jvm 和 cpu 指令层面都有可能发生重排序;
- 重排序在单线程下是无感的, 是不会发生错误的; 但是在多线程情况下就可能出现问题, 比如在单例模式中的双重检查锁机制就是为了避免指令重排带来的问题
public class Singleton {
private static volatile Singleton instance; // volatile保证有序性
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 避免指令重排序
}
}
}
return instance;
}
}
synchronized 是如何保证原子性、可见性、有序性的?
原子性是通过 monitorenter 和 monitorexit 两条指令实现的; 确保两条指令中间的指令执行过程中是不会被终止和被其他线程抢占的, 如果在中间指令执行的过程中当前时间片耗尽了那么下个时间片只能被当前线程占用直到执行完成释放锁;
有序性是因为 synchronized 是重量级锁, 拥有执行权的线程是独占的所以天生具备有序性; 因为 as-if-serial 的约束, 指令重排的原则就是在单线程情况下在不改变代码原有执行效果的下进行的指令顺序优化来实现提升效率;
可见性则是通过读写屏障实现的; 要求一个线程释放之前通过写屏障将内存中的变量写入主内存; 当一个线程获取到锁时读屏障要求清空本地内存中的变量副本并通过读去主内存中的最新值
什么是可重入锁?
可重入锁指的是一个线程重复获取同一把锁; 如果一把锁不可重入那么就可能导致死锁;
synchronized 底层是如何实现可重入锁的?
synchronized 实现可重入锁时通过 monitor 对象的 recursion 字段实现的; 这个字段时一个计数器; 每次获取锁的时候都判断 woner 字段是否为空, 如果为空那么就意味着第一次获取到这把空闲的锁,woner 字段改为当前线程 id 并将 recursion 字段从 0 改成 1, 如果不为空那么就比对 woner 是否为当前线程 id 如果是则证明是进行了重入, 将 recursion 进行+1; 当一个线程执行完成后也是相同的; 先将 recursion 进行-1 操作然后判断 recusion 是否为 0, 为 0 则证明执行完成让出锁的执行权将 woner 字段改为null, 如果不为零那么就证明当前线程依然持有锁
介绍一下 JUC 中的中断机制;介绍一下他是如何实现的
JUC 中的中断机制是一种比起强制杀死一个线程更温和的线程停止的协商机制; 需要被终止的线程在接收到中断标志位标为 true 后自己进行处理和判断并自己决定是否结束当前线程;
主要实现是通过一个中断标志位, 然后线程内部轮训中断状态加上抛出异常的方式实现的;
- 中断的三大 API
void interrupte()
- 将线程的中断标志位置为 true, 不会中断线程线而是要线程自己配合中断
- 如果当前线程处于 (sleep, wait, join 等)阻塞状态, 那么线程则会直接退出阻塞状态并抛出一个 InterruptedException
- 要在异常处理中再次调用
interrupt
才能正常退出; 否则中断标志位会被复位为 false
static boolean interrupted()
- 返回当前线程的中断状态, 然后将中断状态置为 false
Boolean isinterrupted()
- 返回线程中断标志位的值
- 如果线程已经不活动了那么返回 false
介绍一下 LockSupport 这个类
LockSupport 是用于创建锁和其他同步类的线程阻塞唤醒的基础类; 比如 AQS, ReentrantLock; 他的核心方法就是 park 和 unpark 方法; 他通过 unpark 发布许可, park 消费许可的方式实现线程的唤醒和阻塞; 许可 permit 的核心逻辑就是一个初始为 0 的一个 01 隐式状态机 当调用 park 的时候会判断这个许可是否为 1 如果为 1 直接放回; 如果为 0 那么线程就会阻塞直到 permit 变成 1, 线程才会唤醒然后将 permit 置为 0; unpark 方法会将 permit 置为 1
LockSupport 中的 park 和 Object 中的 wait 的区别
上下文环境不同: wait 必须要求线程获取到了锁的前提在才能使用 wait 来阻塞线程; 而 park 方法对上下文没有要求; 唤醒方式不同: LockSupport 可以精确唤醒指定线程; 而 notify 是唤醒 waitSet 中的一个随机线程, 也不是直接唤醒而是将他从 waitSet 重新方法到 EntrySet 中进行锁的竞争 阻塞和唤醒顺序要求不同: wait 必须要在 notify 之前才能被唤醒; 而 unpark 可以在 park 之前一样能唤醒线程成 对于中断的处理方式不同: wait 响应中断会抛出异常; 而 park 不会抛出异常需要手动检查中断状态 (也就是说当一个 park 放回的时候并不能确定是因为获取到了 permit 返回的还是说 park 过程中收到了中断信息, 需要返回后通过判断中断状态决定)
public class ParkExample {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("Thread is going to park");
LockSupport.park();
if (Thread.currentThread().isInterrupted()) {
System.out.println("Thread was interrupted while parked");
} else {
System.out.println("Thread was unparked without interruption");
}
});
thread.start();
try {
Thread.sleep(2000); // 主线程等待2秒
} catch (InterruptedException e) {
e.printStackTrace();
}
// 选择一种方式来使线程返回:中断或许可
// thread.interrupt(); // 方式1:中断线程
LockSupport.unpark(thread); // 方式2:给予许可
}
}
为什么 AQS(如ReentrantLock) 底层选择 LockSupport 而不是 wait/notify
AQS 要求准确的唤醒指定的线程, notify 无法实现只有 LockSupport 能提供; LockSupport 的底层是通过 CAS 和操作系统原语实现的效率更高; 最后也是最重要的就是 wait/notify 必须在有锁的前提下才能使用依赖 synchronized, 而 AQS 本身就是通过 CAS 实现的锁, 如果使用 synchronized 重复上锁的话就没有意义了
讲讲可重入锁和底层实现
可重入锁指的是一个线程在执行过程中多次获取同一把锁; synchronized 关键字实现的底层是通过底层 c++ObjectMonitor 类中的字段, 他记录了当前锁所属的线程, 被该线程重入的次数, 当次数为 0 时表示当前锁未被任何线程持有, 没被重入一次就会自增, 每次被方式就会自减; synchronized 是 java 提供的内置同步机制, 如何使用的通过 java 实现的 ReentrantLock 那么就要确保 lock. lock ()和 lock. unlock ()成对出现, 不然就会出现死锁的情况. 可能会出现一个锁的重入次数无法归零导致其他线程永远无法获取该锁
LockSupport.park()会释放锁资源吗?
#java##面试#并不会,park 只是将当前线程进行阻塞