Java并发编程入门核心笔记
本文基于基础并发编程的常见误区和核心概念整理,保留了通俗的理解方式,同时修正了不准确的表述,补充了底层原理和扩展知识,帮助新手快速掌握多线程安全的核心逻辑。
一、局部变量的跨线程访问规则
原理解释(保留通俗说法) :子线程看到的主线程局部变量相当于加了final,所以不能修改基本数据类型的值,只能使用引用数据类型;final的引用数据类型不能改变指向,跨线程操作只要不改变指向就可以修改对象内部状态。
修正与扩展:
- 准确来说,Java中匿名内部类(包括Thread子类)访问外部方法的局部变量时 ,该变量必须是
final或者effectively final(事实上的final) (Java 8及以后支持)。也就是说,只要局部变量初始化后没有被重新赋值,即使不写final关键字,编译器也会默认它是effectively final,允许内部类访问。 - 为什么有这个限制?局部变量存储在栈中,方法执行结束后栈帧会被销毁,而子线程的生命周期可能比方法长。如果子线程能修改局部变量,会导致栈帧销毁后访问无效内存。所以Java通过强制final/effectively final,让子线程访问的是变量的副本,而不是原栈中的变量。
- 基本数据类型:final意味着值不可变,子线程只能读取不能修改。
- 引用数据类型:final意味着引用指向不可变,但引用指向的对象内部的成员变量是可以修改的。这就是我们通过封装对象实现跨线程通信的原理。
二、volatile的作用与局限------只能保证可见性,不能保证原子性
原理解释(保留通俗说法):volatile并不能保证多线程安全,它只能保证一瞬间的读是准确的,不能保证写的原子性。
经典示例与深度解析:
java
public class XC {
public static void main(String[] args) throws Exception {
XC1 xc1 = new XC1();
Thread t1 = new Thread(){
@Override
public void run(){
for(int i=0;i<100000;i++){
xc1.flag++;
}
}
};
Thread t2 = new Thread(){
@Override
public void run(){
for(int i=0;i<100000;i++){
xc1.flag++;
}
}
};
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(xc1.flag); // 结果永远小于200000
}
}
class XC1 {
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 XC1 {
public int flag ; // 这里可以去掉volatile,synchronized已经保证了可见性
public synchronized void add() {
flag++;
}
}
五、锁的粒度与竞态条件------别让加锁变成"白加"
原理解释(保留通俗说法):非静态同步方法锁的是当前实例对象,一个线程调用其中一个加锁方法时,其他线程不能调用同一个对象的其他加锁方法;锁一定要生效到整个操作完成,否则无效。
经典坑点示例:
java
public class XC {
public static void main(String[] args) throws Exception {
XC1 xc1 = new XC1();
Thread t1 = new Thread(){
@Override
public void run(){
for(int i=0;i<100000;i++){
int w = xc1.get()+1; // get加锁,执行完释放锁
xc1.set(w); // set加锁,执行完释放锁
}
}
};
Thread t2 = new Thread(){
@Override
public void run(){
for(int i=0;i<100000;i++){
int w = xc1.get()+1;
xc1.set(w);
}
}
};
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(xc1.flag); // 结果还是小于200000
}
}
class XC1 {
public int flag ;
public synchronized int get() { return flag; }
public synchronized void set(int x) { flag = x; }
}
为什么还是错了?
get()+1和set(w)是两个独立的同步操作,中间的+1没有被锁保护。线程A执行完get()释放锁后,线程B可能立刻获取锁执行get(),读取到相同的值,最终两个线程都加1后写回,还是少加了一次。
核心原则 :锁的粒度必须覆盖整个需要原子执行的操作序列,否则即使每个步骤都加锁,依然会有线程安全问题。
六、synchronized锁的类型与作用范围
原理解释(完全正确,补充示例):
- 普通同步方法:锁是当前实例对象(this)
- 静态同步方法:锁是当前类的Class对象
- 同步代码块:锁是synchronized括号里配置的任意对象
示例与执行结果分析:
java
public class Shop {
// 对象锁:锁当前实例
public synchronized void m1() { sleep(5000); }
public synchronized void m2() { sleep(5000); }
// 类锁:锁Shop.class对象
public synchronized static 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() | 并行 | 无锁方法不受影响 |
七、伪共享与缓存行填充------提升并发性能的小技巧
原理解释(修正错误):
- 错误纠正:"计算机底层都是C语言"不准确,计算机底层执行的是机器指令,C语言和Java都是编译成机器指令执行的高级语言。
- 缓存行: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;
}
扩展 :Java 8及以后提供了@sun.misc.Contended注解,可以自动实现缓存行填充,需要在JVM启动时添加参数-XX:-RestrictContended生效。
八、并发安全的核心原则
原理解释(补充扩展) :
多进程、多线程、跨服务器并发操作计算准确的核心原则确实是写后读,且与语言无关,但完整的并发安全需要同时满足三大要素:
- 原子性:复合操作不可分割(synchronized/Lock)
- 可见性:一个线程的修改对其他线程可见(volatile/synchronized/Lock)
- 有序性:指令执行顺序符合预期(volatile/synchronized/Lock)
分布式场景扩展:跨服务器并发还需要解决分布式锁、数据一致性(CAP定理、BASE理论)、分布式事务等问题,但"写后读"的核心思想依然适用------任何修改完成后,后续读取必须能看到最新结果,这是所有并发系统正确性的基础。
总结
新手学习并发编程最容易陷入的误区是"以为加了volatile就线程安全"或者"每个方法加锁就万事大吉"。记住两个核心:
- volatile只保证可见性,不保证原子性,复合操作必须用锁
- 锁的粒度必须覆盖整个原子操作序列,否则等于没加锁
理解了这两点,就能解决90%以上的基础多线程安全问题。