学习笔记11——并发编程之并发关键字
并发关键字
synchronized关键字
在应用Sychronized关键字时需要把握如下注意点:
1.一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
2.每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁
3.synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
synchronized关键字的基本用法
-
修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象; ---对象锁
synchronized (new Object()) { System.out.println("block1锁,我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("block1锁,"+Thread.currentThread().getName() + "结束"); } synchronized (new Object()) { System.out.println("block2锁,我是线程" + Thread.currentThread().getName()); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("block2锁,"+Thread.currentThread().getName() + "结束"); }
这种情况两个代码块获取的是两把锁
-
修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象; --对象锁
-
修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象; ---类锁
-
修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。--类锁
-
修饰一个代码块,synchronized(),括号中如果是class对象,则获取的是类锁。
synchronized关键字的实现原理
synchronized
是 Java 中实现线程同步的核心机制,其底层实现基于 对象监视器(Monitor)、对象头中的锁状态标记 和 锁升级优化。以下从 JVM 层、操作系统层和硬件层逐步解析其实现原理。
1.对象监视器:
Monitor 是 JVM 实现同步的核心机制,每个对象关联一个 Monitor。其结构包括:
-
Owner:当前持有锁的线程。
-
EntryList:等待锁的线程队列(处于
BLOCKED
状态)。 -
WaitSet:调用
wait()
后进入等待的线程队列(处于WAITING
或TIMED_WAITING
状态)。
Monitor 的工作流程:
-
线程尝试通过 CAS 修改对象头获取锁:
-
若成功,设置 Owner 为当前线程,进入临界区。
-
若失败,线程进入 EntryList 阻塞等待。
-
-
释放锁时,Owner 清空,唤醒 EntryList 中的线程重新竞争。
从图中可以看出,每个线程对Object对象的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。
2.对象头与状态标记:
每个Java对象在内存中分为3部分
-
对象头(Header):存储锁状态、GC 信息、哈希码等。
-
实例数据(Instance Data):对象的成员变量。
-
对齐填充(Padding):确保对象按 8 字节对齐。
对象头结构
-
Mark Word(64 bits):存储锁状态、线程 ID、GC 分代年龄等。
-
Klass Pointer(64 bits):指向类元数据的指针。
3.锁升级优化
synchronized实现的同步锁,1.6之前称之为重量级锁,重量锁会直接使用操作系统的底层的锁,会造成线程排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,从Java 1.6开始,JVM进行了优化,synchronized不一定直接使用重量锁,一共有四种状态:无锁、偏向锁、轻量级锁和重量级锁。
锁膨胀方向:无锁——>偏向锁——>轻量级锁——>重量级锁 锁只可以升级不可降级
偏向锁:
核心思想:偏向于第一个获取锁的线程,锁对象会记住首次访问它的线程 ID(记录在对象头的 Mark Word 中)。若后续没有其他线程竞争,该线程再次进入同步块时 无需加锁/解锁操作,仅需检查线程 ID 是否匹配。
1. 初次获取锁
-
CAS 设置线程 ID:通过 CAS 操作将当前线程 ID 写入对象的 Mark Word。
-
标记为偏向模式:对象头中的锁标志位更新为偏向锁状态(
101
)。
2. 再次进入同步块
-
检查线程 ID:判断对象头中的线程 ID 是否与当前线程一致:
-
✅ 一致:直接执行代码,无任何同步开销。
-
❌ 不一致:检查对象是否仍可偏向:
-
可偏向:尝试通过 CAS 竞争锁(重新偏向)。
-
已偏向其他线程:触发 偏向锁撤销,可能升级为轻量级锁。
-
-
偏向锁是针对于单个线程而言的,线程获得锁之后就不会再有解锁等操作
了,这样可以省略很多开销。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了
轻量锁: JVM 在存在低强度线程竞争时采用的锁优化机制,其核心是通过 CAS 自旋 减少线程阻塞的开销,避免直接升级为重量级锁(操作系统互斥锁)。
核心思想:通过线程的 CAS 自旋(循环尝试获取锁)替代直接阻塞线程,降低上下文切换的开销。适用于 线程交替执行同步块、竞争短暂且稀疏 的场景。
1. 加锁过程
-
创建 Lock Record
-
线程进入同步块时,在栈帧中分配一个 Lock Record,用于保存锁对象的原始 Mark Word。
-
-
CAS 竞争锁
-
尝试通过 CAS 操作将对象头的 Mark Word 更新为指向 Lock Record 的指针:
-
✅ 成功:对象头的锁标志位变为
00
(轻量级锁状态),线程获得锁。 -
❌ 失败:说明存在竞争,触发 自旋重试 或 锁升级。
-
-
2. 解锁过程
-
CAS 还原 Mark Word
-
通过 CAS 将 Lock Record 中保存的原始 Mark Word 写回对象头。
-
-
还原成功
-
对象恢复为无锁状态(标志位
01
)。
-
-
还原失败
-
说明锁已升级为重量级锁,需通过操作系统级别的锁机制释放。
-
轻量级锁的升级与自旋优化
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。但是也有问题,1.如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在原地等待空消耗cpu。2.本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁
1. 自旋策略
-
固定次数自旋:早期 JVM 采用固定次数的自旋(如 10 次),若失败则升级为重量级锁。
-
自适应自旋(Adaptive Spinning):JDK 1.6 后引入,根据 历史自旋成功率动态调整自旋次数(如上次成功则增加次数,失败则减少)。
2. 升级条件
-
自旋失败:多次 CAS 尝试后仍无法获取锁。
-
竞争加剧:超过 JVM 自旋阈值(由
-XX:PreBlockSpin
控制,默认值因 JVM 实现而异)。 -
升级路径:轻量级锁 → 重量级锁(线程阻塞,依赖操作系统互斥量)。
重量锁:是 JVM 在锁竞争激烈时的最终锁机制,其核心是 依赖操作系统互斥量(Mutex)和条件变量(Condition Variables)实现线程同步,通过线程阻塞和唤醒机制解决高并发竞争问题。当轻量级锁自旋失败(线程竞争激烈)时,升级为重量级锁,通过 操作系统内核调度 管理线程阻塞与唤醒。操作系统级别的锁机制通常支持公平性策略(如 FIFO 队列),避免线程饥饿。
synchronized关键字的特性
-
可重入性:又称递归锁,同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁。前提锁对象是同一个对象或class,不会因为之前已经获取就阻塞。
public class ReentrantDemo { public synchronized void methodA() { methodB(); // 可重入:直接进入 methodB 的同步块 } public synchronized void methodB() { // 代码逻辑 } }
-
内存可见性:线程释放锁时,会将共享变量的修改刷新到主内存;获取锁时,会从主内存重新加载变量值
-
有序性:禁止指令重排序:临界区内的代码不会被编译器或处理器重排序破坏逻辑。
synchronized关键字的优化策略
1. 锁消除(Lock Elimination)
-
触发条件:JIT 编译器检测到不存在共享数据竞争的锁。
-
示例:局部对象锁(线程私有,无需同步)。
public void lockEliminationDemo() { Object localLock = new Object(); synchronized (localLock) { // 锁被消除 System.out.println("This lock is unnecessary"); } }
2. 锁粗化(Lock Coarsening)
-
触发条件:多次连续的锁操作合并为一次,减少锁开销。
-
示例:循环内重复加锁。
public void lockCoarseningDemo() { synchronized (this) { // 合并多次锁操作为一次 for (int i = 0; i < 100; i++) { // 操作共享资源 } } }
3. 自旋优化(Adaptive Spinning)
-
轻量级锁失败后:线程不立即阻塞,而是自旋重试(默认次数为 10 次,JDK 6 后改为自适应)。
volatile关键字
volatile
是 Java 中用于解决 多线程内存可见性 和 指令重排序 问题的关键字。它提供了一种轻量级的同步机制,确保变量的修改对所有线程立即可见,同时禁止编译器和处理器对代码进行某些优化。注意不保证复合操作的原子性,需结合锁或原子类使用。
volatile的核心作用
-
保证可见性:在多线程环境下,每个线程可能将共享变量缓存到自己的 工作内存(CPU 缓存) 中,导致一个线程修改了变量的值,其他线程无法立即看到最新值;若使用volatile修饰变量,每次读写都直接操作 主内存,绕过线程的工作内存。强制其他线程在读取
volatile
变量时,清空本地缓存,重新从主内存加载最新值。public class VisibilityDemo { private volatile boolean flag = false; public void writer() { flag = true; // 写操作立即刷新到主内存 } public void reader() { while (!flag) { // 每次读取都从主内存加载最新值 // 循环等待 } System.out.println("Flag is now true"); } }
-
禁止指令重排序
-
问题背景: 编译器和处理器为了提高性能,可能会对代码执行顺序进行 重排序(如单例模式中的双重检查锁定问题)。
-
volatile 的解决方案:
-
通过插入 内存屏障(Memory Barrier),禁止对
volatile
变量前后的指令进行重排序。 -
确保
volatile
变量的写操作对其他线程可见的顺序符合程序预期。
-
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // 禁止重排序,确保对象完全初始化 } } } return instance; } }
-
volatile的实现原理
1. 内存屏障(Memory Barrier):又称内存栅栏,是一个cpu指令。
JVM 会在 volatile
变量的读写操作前后插入特定类型的内存屏障,确保以下两点:
-
可见性:强制将工作内存的修改刷新到主内存,或从主内存重新加载变量值。
-
有序性:禁止编译器或处理器对指令进行重排序。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1、L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量所在缓存行的数据写到系统内存。为了保证各个处理器的缓冲是一致的,实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓冲的值是不是过期了,当处理器发现自己缓存行对应的 内存的地址被修改了,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓冲中。所有多核处理器发现本地缓存失效后,就会从内存中重读该变量的数据,也就获取到了最新的值。
屏障类型 | 作用 |
---|---|
LoadLoad | 确保当前读操作之前的其他读操作已完成。 |
StoreStore | 确保当前写操作之前的其他写操作对其他线程可见。 |
LoadStore | 确保当前读操作之后的写操作不会被重排序到读操作之前。 |
StoreLoad | 确保当前写操作之后的所有读/写操作不会被重排序到写操作之前(全能屏障,开销最大)。 |
2. 具体规则
-
写操作(Write): 在写
volatile
变量后插入 StoreStore 和 StoreLoad 屏障,确保:-
当前变量的修改对其他线程可见。
-
写操作不会被重排序到后续操作之后。
-
-
读操作(Read): 在读
volatile
变量前插入 LoadLoad 和 LoadStore 屏障,确保:-
后续操作不会被重排序到读操作之前。
-
每次读取都能获取最新值。
-
volatile的使用场景
1.状态标志位
多线程中通过 volatile
变量作为开关控制线程执行。
public class TaskRunner implements Runnable { private volatile boolean running = true; public void stop() { running = false; // 其他线程调用此方法后,立即停止任务 } @Override public void run() { while (running) { // 执行任务 } } }
2.单例模式
通过 volatile
解决双重检查锁定中的重排序问题
3.无锁编程
volatile 与 synchronized 的对比
维度 | volatile | synchronized |
---|---|---|
可见性 | 保证变量的可见性 | 保证临界区内所有变量的可见性 |
原子性 | 仅单次读/写操作原子 | 保证代码块内操作的原子性 |
有序性 | 禁止指令重排序 | 通过锁机制隐式保证有序性 |
性能 | 轻量级(无上下文切换开销) | 重量级(涉及锁升级和线程阻塞) |
适用场景 | 状态标志、单次发布、无锁编程 | 复杂同步逻辑、复合操作 |
final关键字
final的作用
修饰类:禁止类被继承(即不可有子类)。
修饰方法:禁止方法被子类重写(Override)。但是可以重载。
修饰变量:
-
基本类型变量:变量值不可修改,必须在声明时或构造方法中初始化。
final int MAX_VALUE = 100; // 声明时初始化 final double PI; public MyClass() { PI = 3.14; } // 构造方法中初始化
-
引用类型变量:引用指向的对象不可变,但对象内部状态可能可变。
final List<String> list = new ArrayList<>(); list.add("Java"); // 允许操作对象内容 // list = new LinkedList<>(); // 编译错误,禁止重新赋值
-
常量定义:全局常量
public static final String LOG_TAG = "System";