并发编程(5)
抛异常时会释放锁。
当线程在 synchronized
块内部抛出异常时,会自动释放对象锁。
public class ExceptionUnlockDemo {private static final Object lock = new Object();public static void main(String[] args) {Thread t1 = new Thread(() -> {synchronized (lock) {System.out.println("线程1获取到锁");try {// 模拟操作Thread.sleep(1000);// 抛出异常throw new RuntimeException("模拟异常");} catch (InterruptedException | RuntimeException e) {System.out.println("线程1捕获异常: " + e.getMessage());}// 异常发生后,锁已经被释放System.out.println("线程1执行完毕");}});Thread t2 = new Thread(() -> {// 短暂等待,确保t1先执行try {Thread.sleep(500);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("线程2尝试获取锁");synchronized (lock) {System.out.println("线程2获取到锁");}});t1.start();t2.start();}
}
结果:
线程1获取到锁
线程1捕获异常: 模拟异常
线程2尝试获取锁
线程2获取到锁
线程1执行完毕
从输出能够看出,线程 1 在抛出异常之后,线程 2 就能够获取到锁,这表明线程 1 的锁已经因为异常而被释放了。
加锁的区域就是同步代码块
在 Java 中,被锁保护的代码区域被称为同步代码块(Synchronized Block)。当多个线程访问同一把锁的同步代码块时,会强制串行执行,从而保证线程安全。同步的意思就是说比如说一个线程在执行时访问了一个别锁的共享资源,在时间片结束后还占着资源,另一个线程也不能访问
public class SynchronizedExample {private final Object lock = new Object(); // 锁对象// 方法级同步(隐式锁为 this)public synchronized void method1() {// 同步方法的代码}// 代码块级同步(显式指定锁对象)public void method2() {synchronized (lock) {// 同步代码块,同一时刻只能被一个线程执行}}
}
monitor进monitor出
对于我们人来说,一个程序的执行开始和执行结束是可以理解的,但机器不行。我们计算机内部的指令经过编译之后是一长串的代码,程序以数组的形式存到页里,我们加的锁的区域在数组里不允许同时被访问。那么我们需要一些表记指出哪些区域的代码是被锁的不能同时访问。
首先我们要进行范围标记枷锁加锁我们知道这块资源是加锁标记还得。注意我们加锁是这样的。
第一,我们对要操作的资源加上所标记。
第二,我们要操作的资源这块是有这些指令对资源进行操作,这一堆指令要对资源进行操作,其中指令到这些区域。这些区域对它的操作不能是同时进行的,所以我们加锁的话有两个区域,一个是对资源本身加标记,另外一个对操作的资源的指令。也得指定所在的范围同步范围。
monitor进monitor出就是标记这些指令里那一块是不能同时被线程所操作。
JAVA对象头
snchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit.
我们在堆区域创建对象,每一个对象都有锁表记和对象表记,所属类型:类的地址指向方法区。
如果有数组的话有数组长度。
如果一个64位的操作系统可以安装32位或64位的程序。32位的操作系统只可以安装32位程序。如果cpu的一个时钟周期能够运算32位,那他只能安装32位的操作系统。那如果一个程序是64位的程序,它里边一个字宽就是64位。是32位的程序,那么它就只能说一个字宽是32位。从这个推理下,我们在64位的操作系统下安装了32位的程序,那么它一个字就是32位。也就是说字宽最终是受程序的位数影响
锁的升级与对比
在 Java 中,锁的升级是 JVM 为了优化锁性能而引入的机制。随着多线程竞争的加剧,锁会从无锁状态逐步升级为偏向锁、轻量级锁,最终升级为重量级锁。这种升级过程是不可逆的,旨在平衡不同竞争程度下的性能开销。
1. 锁升级的流程
锁的状态会根据竞争情况逐步升级:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
1.1 无锁状态(Normal)
- 特点:对象头的 Mark Word 存储哈希码、分代年龄等信息。
- 场景:没有线程访问同步块时。
1.2 偏向锁(Biased Locking)
- 适用场景:只有一个线程频繁访问同步块。
- 原理:
- 首次获取锁时,JVM 会在 Mark Word 中记录线程 ID(通过 CAS 操作)。
- 该线程后续进入同步块时,无需任何同步操作,直接判断 Mark Word 中的线程 ID 是否匹配。
- 优点:无竞争时几乎无额外开销,性能最优。
- 缺点:当其他线程尝试竞争锁时,需要撤销偏向锁,消耗 CPU。
1.3 轻量级锁(Lightweight Locking)
- 适用场景:多个线程交替访问同步块(无竞争)。
- 原理:
- 线程在栈帧中创建锁记录(Lock Record),并通过 CAS 将 Mark Word 复制到锁记录中。
- 同时,Mark Word 会指向锁记录的地址。
- 如果 CAS 成功,线程获得轻量级锁;如果失败,表示有竞争,锁会升级为重量级锁。
- 优点:竞争不激烈时避免线程阻塞,减少用户态和内核态的切换。不进阻塞队列,快速循环看看自己请求的资源有没有解锁。
- 缺点:频繁 CAS 操作可能消耗 CPU。
1.4 重量级锁(Heavyweight Locking)
- 适用场景:多个线程同时竞争锁。
- 原理:
- 依赖操作系统的互斥量(Mutex),线程会被阻塞并进入等待队列。
- 锁释放时,需要唤醒等待的线程,涉及用户态和内核态的切换。
- 缺点:线程阻塞和唤醒的开销大,性能最差。重量解锁的加锁和解锁都挺麻烦,不仅仅是修改对象头标记这么简单,还有很多枷锁过程,枷锁竞争过程。
四种锁的对比
特性 | 无锁 | 偏向锁 | 轻量级锁 | 重量级锁 |
---|---|---|---|---|
适用场景 | 无同步需求 | 单线程访问 | 多线程交替访问 | 多线程同时竞争 |
实现方式 | Mark Word 存储哈希码等信息 | Mark Word 存储线程 ID | Mark Word 指向线程栈中的锁记录 | Mark Word 指向 Monitor 对象 |
线程状态 | 无需阻塞 | 无需阻塞 | 自旋等待 | 阻塞等待 |
性能开销 | 最低 | 低(首次 CAS) | 中等(CAS 操作) | 最高(内核态切换) |
优缺点 | 无同步开销 | 无竞争时最优 | 避免线程切换 | 线程频繁阻塞 / 唤醒 |
释放机制 | 无需释放 | 线程退出同步块时不释放,其他线程竞争时撤销 | 解锁时通过 CAS 恢复 Mark Word | 解锁时唤醒等待线程 |
现在是几个线程对资源进行竞争只有一个才能竞争成功!只有一个竞争成功加上了锁。其他就会竞争失败。竞争失败的话会竞争失败会进阻塞队列。为什么进入阻塞队列:成功的线程即使时间片到期了,下次还是选中他可以立马执行。效率最高。每个加锁资源对应一个阻塞队列
然后第一个成功占资源的线程已经执行结束后,会给所有在阻塞队列的线程发通知,让他们回到就绪队列。
比较并且交换
比较并交换(Compare-and-Swap, CAS) 是一种实现无锁(Lock-Free)算法的原子操作,用于在多线程环境中实现同步。它允许线程在不使用锁的情况下安全地修改共享数据,从而避免了锁带来的上下文切换和线程阻塞开销。这种方式也是实现了写后读。
- 如果内存地址 V 中的值等于预期旧值 A,则将该位置的值更新为新值 B,并返回
true
。 - 如果内存地址 V 中的值不等于预期旧值 A,则不执行更新,返回
false
。
但也有缺点,他是分两步的先比较再交换。比如说线程1先比较,发现没问题可以交换,但此时时间片到期。线程2进入也比较发现也没问题,也要交换就有问题了。 他不是原子操作,所以无法实现。只能调用操作系统底层。
流水线技术。他减少了电路的切换。提升的整体速度。但是指令顺序会发生变化,会导致后边有个指令重排序导致的并发问题。
java中哪些类是线程安全的?concurrent 开头的是线程安全的集合。Atomic. 开头的也是线程安全的
private AtomicInteger atomicI = new AtomicInteger(0);private int i = 0;public static void main(String[] args) {final Counter cas = new Counter();List<Thread> ts = new ArrayList<Thread>(600);long start = System.currentTimeMillis();for (int j = 0; j < 100; j++) {Thread t = new Thread(new Runnable() {@Overridepublic void run() {for (int i = 0; i < 10000; i++) {cas.count();cas.safeCount();}}});ts.add(t);}for (Thread t : ts) {t.start();}// 等待所有线程执行完成for (Thread t : ts) {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}}System.out.println(cas.i);System.out.println(cas.atomicI.get());System.out.println(System.currentTimeMillis() - start);}/** * 使用CAS实现线程安全计数器 */private void safeCount() {for (;;) {int i = atomicI.get();boolean suc = atomicI.compareAndSet(i, ++i);if (suc) {break;}}}/*** 非线程安全计数器*/private void count() {i++;}}
一个使用AtomicInteger
实现的线程安全计数器atomicI
,另一个是普通的非线程安全整数i
。在多线程环境下运行这两个计数器,会看到不同的结果。
主要分析
-
线程安全问题:
count()
方法使用普通的i++
操作,在多线程环境下会出现竞态条件(Race Condition),导致最终结果小于预期值。safeCount()
方法使用AtomicInteger
和 CAS 操作,保证了原子性,最终结果是正确的。
-
CAS 操作的实现:
for (;;) {int i = atomicI.get();boolean suc = atomicI.compareAndSet(i, ++i);if (suc) {break;} }
这段代码通过循环 CAS 操作来保证原子性:
- 获取当前值
- 尝试更新为新值
- 如果失败则重试,直到成功
-
性能考虑:
- 原子操作虽然避免了锁的开销,但在高竞争环境下可能会因为频繁重试而降低效率
- 非原子操作虽然没有这些开销,但结果是错误的,所以不能只考虑性能
输出结果
对于 100 个线程,每个线程执行 10000 次递增操作:
atomicI.get()
的输出结果应为:100 * 10000 = 1,000,000i
的输出结果通常会小于 1,000,000,因为非线程安全操作会导致部分递增丢失- 执行时间取决于系统环境,但通常原子操作会比非原子操作慢一些
- 我们看由于它没有加锁。他100万字肯定有相互覆盖,最终的结果肯定不到100万,就极大概率不到100万,有没有看到100万的一个极小的可能?比较大的可能是到不了100万。因为会有相互覆盖的问题。能理解的加九。对他100万次调用
ABA问题
在多线程编程中,ABA 问题(ABA Problem)是使用 CAS(Compare-and-Swap) 操作时可能遇到的一种特殊问题。它发生在一个值从 A 变为 B,然后再变回 A 的过程中,而 CAS 操作无法感知这个中间变化,误认为值没有被修改过。
1. ABA 问题的本质
CAS 操作的核心逻辑是:“如果当前值是预期值 A,则将其更新为 B”。但如果在检查期间,值经历了 A → B → A
的变化,CAS 会认为值 “没有变化” 并成功更新,而实际上值已经被其他线程修改过,可能导致潜在的错误。
2. ABA 问题示例
假设有一个共享变量 int value = 10
,两个线程 T1 和 T2 同时操作它:
- T1 读取 value:T1 准备将
value
从 10 增加到 11,它先读取value
的当前值为 10。 - T2 修改 value:T2 先将
value
改为 20,然后又改回 10(即10 → 20 → 10
)。 - T1 执行 CAS:T1 执行
CAS(预期值=10, 新值=11)
,发现当前值确实是 10,认为没有变化,成功将value
改为 11。
问题:T1 认为 value
没有被修改过,但实际上它经历了 10 → 20 → 10
的变化,可能导致 T1 的操作基于错误的假设(例如,T1 可能期望 value
自从读取后没有被其他线程动过)。
3. ABA 问题的危害
- 数据一致性风险:在某些业务逻辑中,中间状态的变化可能影响最终结果。例如:
- 链表操作:删除节点 A,插入新节点 B(内容与 A 相同),此时 CAS 可能误判节点未变。
- 账户余额:余额从 100 变为 200 再变回 100,CAS 可能允许重复扣款。
- 逻辑错误:某些操作依赖值的连续性变化,而 ABA 可能破坏这种假设。
4.解决方法
解决 ABA 就加版本号就可以了
线程可见性
一个线程对共享变量做出了修改,其他线程能及时读取到最新的修改,这叫线程可见性
线程间如何通信和进程间如何通信
进程是由线程组成的。进程所有的功能线程全都有。进程所拥有的功能线程全都有。然后。线程拥有的功能进程不一定有进程的通信方式线程全都有
里边通信其实是指交换信息的意思,就把消息传递过去,线程的通信方式有两种共享内存和消息传递