一、局部变量的跨线程访问规则
原理解释(通俗版)
子线程看到的主线程局部变量相当于"加了final",所以不能修改基本数据类型的值,只能使用引用数据类型;final的引用数据类型不能改变指向,跨线程操作只要不改变指向就可以修改对象内部状态。
修正与深度扩展
准确来说,Java中匿名内部类 (包括Thread子类、Runnable实现类)访问外部方法的局部变量时,该变量必须是final或effectively final (事实上的final)。Java 8及以后支持effectively final,即局部变量初始化后从未被重新赋值,编译器自动认可。
为什么有这个限制?
局部变量存储在栈中,方法执行结束后栈帧会被销毁,而子线程的生命周期可能比方法长。如果子线程能修改局部变量,会导致栈帧销毁后访问无效内存。所以Java通过强制final/effectively final,让子线程访问的是变量的副本,而不是原栈中的变量。
- 基本数据类型 :
final意味着值不可变,子线程只能读取不能修改。 - 引用数据类型 :
final意味着引用指向不可变,但指向的对象内部的成员变量可以修改。这就是我们通过封装对象实现跨线程通信的原理。
代码示例
java
public class LocalVariableDemo {
public static void main(String[] args) {
// effectively final:未重新赋值
String message = "Hello";
// 可以修改对象内部状态,但不能重新赋值 message = "World";
StringBuilder sb = new StringBuilder("Hello");
new Thread(() -> {
System.out.println(message); // 可以读取
sb.append(" from thread"); // 允许修改对象内部状态
// message = "changed"; // 编译错误:变量被内部类使用,必须为final或effectively final
}).start();
}
}
二、volatile的作用与局限------只能保证可见性,不能保证原子性
原理解释(通俗版)
volatile并不能保证多线程安全,它只能保证"一瞬间的读是准确的",不能保证"写"的原子性。
经典示例与深度解析
java
public class VolatileDemo {
public static void main(String[] args) throws Exception {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) counter.flag++;
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) counter.flag++;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.flag); // 结果永远小于200000
}
}
class Counter {
public volatile int flag;
}
为什么结果不是200000?
flag++不是原子操作,它包含三个独立步骤:
- 从主内存读取
flag的值到线程工作内存(读) - 在工作内存中对值加1(改)
- 将修改后的值写回主内存(写)
volatile只能保证可见性 和禁止指令重排序,但无法保证复合操作的原子性。
- 可见性 :一个线程修改
volatile变量后会立即刷新到主内存,其他线程读取时会先清空工作内存的旧值,从主内存读取最新值。 - 原子性缺失 :线程A读取
flag=100后还没来得及加1,线程B也读取了flag=100,两个线程都加1后写回,最终flag只增加了1 ------ 这就是竞态条件(Race Condition)。
底层实现
volatile通过lock前缀指令触发CPU的缓存一致性协议 (如MESI),使其他CPU缓存中该变量的副本失效;同时插入内存屏障禁止指令重排序。这一特性在**单例模式的双重检查锁(DCL)**中至关重要。
三、写后读原则------可见性的核心
原理解释(通俗版)
写后读就是一个线程写完堆内存数据后,其他线程才能读取到最新的数据。
修正与扩展
"写后读"是并发编程中可见性的核心保证,对应Java内存模型中的happens-before原则:
volatile写-读规则 :对一个volatile变量的写操作,happens-before于后续对这个变量的读操作。- 锁规则 :对一个锁的解锁 操作,happens-before于后续对这个锁的加锁操作。
通俗理解:只要满足写后读原则,前一个线程的所有写操作结果,都会对后一个执行读操作的线程完全可见。
⚠️ 注意:写后读只保证可见性,不保证原子性,复合操作仍会有线程安全问题。
四、synchronized的锁机制------同时保证三大特性
原理解释(修正不准确表述)
synchronized不是"不让其他线程拷贝方法入栈"(每个线程都有自己的栈帧,方法局部变量是线程私有的),而是通过**对象监视器(monitor)**实现互斥访问,同一时间只有一个线程能进入被同一个锁保护的同步代码块/方法。
synchronized保证的三大特性
| 特性 | 说明 |
|---|---|
| 原子性 | 被保护的代码块同一时间只有一个线程执行,"读-改-写"复合操作变成原子操作,不会被打断 |
| 可见性 | 线程释放锁前会将工作内存的所有修改刷新到主内存;线程获取锁时会清空工作内存,从主内存重新读取共享变量,完美保证写后读 |
| 有序性 | 禁止锁内的指令重排序到锁外,保证代码执行顺序符合预期 |
正确示例(结果稳定为200000)
java
class SafeCounter {
private int flag; // synchronized已经保证了可见性,无需volatile
public synchronized void add() {
flag++;
}
}
public class SynchronizedDemo {
public static void main(String[] args) throws Exception {
SafeCounter counter = new SafeCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) counter.add();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) counter.add();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.flag); // 稳定输出200000
}
}
五、锁的粒度与竞态条件------别让加锁变成"白加"
原理解释(通俗版)
非静态同步方法锁的是当前实例对象 ,一个线程调用其中一个加锁方法时,其他线程不能调用同一个对象的其他加锁方法;锁一定要生效到整个操作完成,否则无效。
经典坑点示例
java
class CounterWithGetSet {
private int flag;
public synchronized int get() { return flag; }
public synchronized void set(int x) { flag = x; }
}
public class WrongLockGranularity {
public static void main(String[] args) throws Exception {
CounterWithGetSet counter = new CounterWithGetSet();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
int w = counter.get() + 1; // ① 加锁,执行完释放锁
counter.set(w); // ② 加锁,执行完释放锁
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
int w = counter.get() + 1;
counter.set(w);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.flag); // 结果还是小于200000!
}
}
问题分析
get() + 1和set(w)是两个独立的同步操作,中间的+1没有被锁保护。线程A执行完get()释放锁后,线程B可能立刻获取锁执行get(),读取到相同的值,最终两个线程都加1后写回,还是少加了一次。
核心原则
锁的粒度必须覆盖整个需要原子执行的操作序列,否则即使每个步骤都加锁,依然会有线程安全问题。
正确做法
java
public synchronized void increment() {
flag++;
}
// 或者使用同步代码块保证 get、加1、set 连续执行
六、synchronized锁的类型与作用范围
原理解释
| 锁类型 | 写法 | 锁对象 |
|---|---|---|
| 普通同步方法 | public synchronized void method() |
当前实例对象 this |
| 静态同步方法 | public static synchronized void method() |
当前类的Class对象 Shop.class |
| 同步代码块 | synchronized(obj) { ... } |
括号中指定的任意对象 |
示例与执行结果分析
java
public class Shop {
// 对象锁
public synchronized void m1() { sleep(5000); }
public synchronized void m2() { sleep(5000); }
// 类锁
public static synchronized void m3() { sleep(5000); }
public static synchronized void m4() { sleep(5000); }
// 无锁方法
public void m5() { sleep(5000); }
public static void m6() { sleep(5000); }
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) {}
}
}
| 线程1执行 | 线程2执行 | 执行结果 | 原因 |
|---|---|---|---|
x1.m1() |
x1.m2() |
串行 | 同一个对象锁 |
x1.m1() |
x2.m1() |
并行 | 不同对象锁 |
x1.m1() |
x1.m3() |
并行 | 对象锁和类锁是不同的锁 |
Shop.m3() |
Shop.m4() |
串行 | 同一个类锁 |
x1.m1() |
x1.m5() |
并行 | 无锁方法不受影响 |
七、伪共享与缓存行填充------提升并发性能的小技巧
原理解释
-
缓存行 :CPU缓存的基本单元是64字节,即使只读取1字节的变量,CPU也会加载该变量所在的连续64字节数据到缓存。
-
伪共享 :多个线程访问同一个缓存行中的不同变量时,一个线程修改变量会导致整个缓存行失效,其他线程需要重新从主内存加载,即使它们访问的是不同变量,也会互相影响性能。
-
缓存行填充:通过在变量前后填充无用的字节,让一个变量独占一个缓存行,避免与其他变量共享,从而解决伪共享问题。
示例
java
// 未填充,可能发生伪共享
class VolatileLong {
public volatile long value = 0L;
}
// 缓存行填充,避免伪共享(每个long占8字节,8*8=64字节)
class PaddedVolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6, p7; // 填充56字节,使value独占64字节
}
Java 8+ 优雅方案:@Contended
java
import sun.misc.Contended;
@Contended
class ContendedLong {
public volatile long value = 0L;
}
需要JVM启动参数:-XX:-RestrictContended 才能生效。
八、并发安全的核心原则
多进程、多线程、跨服务器并发操作计算准确的核心原则确实是写后读,且与语言无关。但完整的并发安全需要同时满足三大要素:
| 要素 | 说明 | 解决方案 |
|---|---|---|
| 原子性 | 复合操作不可分割 | synchronized / Lock / AtomicXXX |
| 可见性 | 一个线程的修改对其他线程可见 | volatile / synchronized / Lock |
| 有序性 | 指令执行顺序符合预期 | volatile / synchronized / Lock |
分布式场景扩展
跨服务器并发还需要解决分布式锁、数据一致性(CAP定理、BASE理论)、分布式事务等问题,但"写后读"的核心思想依然适用 ------ 任何修改完成后,后续读取必须能看到最新结果,这是所有并发系统正确性的基础。
九、并发编程新手常见误区总结
误区一:以为加了volatile就线程安全
详细说明:
-
volatile关键字只能保证变量的可见性(一个线程修改后其他线程立即可见),但无法保证复合操作的原子性
-
典型错误场景:计数器自增(i++)操作,看似简单但实际包含读取-修改-写入三个步骤
-
示例代码:
javavolatile int count = 0; // 线程不安全,因为count++不是原子操作 public void increment() { count++; } -
正确做法:对于复合操作,必须使用锁机制(synchronized或Lock)来保证原子性
误区二:每个方法加synchronized就万事大吉
详细说明:
-
锁的粒度必须覆盖整个原子操作序列,否则等于没加锁
-
典型错误场景:银行转账操作(检查余额-扣款-入账),如果只对单个方法加锁而没对整个流程加锁,仍会出现线程安全问题
-
示例代码:
java// 错误示范 public synchronized void withdraw(int amount) {...} public synchronized void deposit(int amount) {...} // 转账操作仍可能出问题 public void transfer(Account to, int amount) { withdraw(amount); // 这两步之间可能被其他线程打断 to.deposit(amount); } -
正确做法:确保锁的范围覆盖整个原子操作序列
-
基础阶段:
- 深入理解上述两个核心误区
- 掌握synchronized关键字的使用场景和限制
- 理解happens-before原则
-
进阶内容(在掌握基础后):
- Lock接口及其实现类(ReentrantLock等)
- AQS(AbstractQueuedSynchronizer)框架
- 并发容器(ConcurrentHashMap、CopyOnWriteArrayList等)
- 线程池(ThreadPoolExecutor)及其配置参数
- 并发设计模式(如生产者-消费者模式)
理解并避免这两个基础误区,就能解决90%以上的初级多线程安全问题。随着经验积累,再逐步掌握更高级的并发编程技术。