并发编程二

Java并发编程入门核心笔记

本文基于基础并发编程的常见误区和核心概念整理,保留了通俗的理解方式,同时修正了不准确的表述,补充了底层原理和扩展知识,帮助新手快速掌握多线程安全的核心逻辑。

一、局部变量的跨线程访问规则

原理解释(保留通俗说法) :子线程看到的主线程局部变量相当于加了final,所以不能修改基本数据类型的值,只能使用引用数据类型;final的引用数据类型不能改变指向,跨线程操作只要不改变指向就可以修改对象内部状态。

修正与扩展

  1. 准确来说,Java中匿名内部类(包括Thread子类)访问外部方法的局部变量时 ,该变量必须是final或者effectively final(事实上的final) (Java 8及以后支持)。也就是说,只要局部变量初始化后没有被重新赋值,即使不写final关键字,编译器也会默认它是effectively final,允许内部类访问。
  2. 为什么有这个限制?局部变量存储在栈中,方法执行结束后栈帧会被销毁,而子线程的生命周期可能比方法长。如果子线程能修改局部变量,会导致栈帧销毁后访问无效内存。所以Java通过强制final/effectively final,让子线程访问的是变量的副本,而不是原栈中的变量。
  3. 基本数据类型:final意味着值不可变,子线程只能读取不能修改。
  4. 引用数据类型: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++不是原子操作,它包含三个独立步骤:

  1. 从主内存读取flag的值到线程工作内存(读)
  2. 在工作内存中对值加1(改)
  3. 将修改后的值写回主内存(写)

volatile只能保证可见性禁止指令重排序,但无法保证复合操作的原子性:

  • 可见性:就是"一瞬间的读是准确的"。一个线程修改volatile变量后会立即刷新到主内存,其他线程读取时会先清空工作内存的旧值,从主内存读取最新值。
  • 原子性缺失:线程A读取flag=100后还没来得及加1,线程B也读取了flag=100,两个线程都加1后写回,最终flag只增加了1,这就是竞态条件(Race Condition)

底层实现 :volatile通过lock前缀指令触发CPU的缓存一致性协议(如MESI),使其他CPU缓存中该变量的副本失效,从而保证可见性;同时插入内存屏障禁止指令重排序,这在单例模式的双重检查锁(DCL)中至关重要。

三、写后读原则------可见性的核心

原理解释(保留通俗说法):写后读就是一个线程写完堆内存数据后,其他线程才能读取到最新的数据。

修正与扩展

"写后读"是并发编程中可见性 的核心保证,对应Java内存模型中的happens-before原则

  1. volatile写-读规则:对一个volatile变量的写操作,happens-before于后续对这个变量的读操作。
  2. 锁规则:对一个锁的解锁操作,happens-before于后续对这个锁的加锁操作。

通俗理解:只要满足写后读原则,前一个线程的所有写操作结果,都会对后一个执行读操作的线程完全可见。但注意:写后读只保证可见性,不保证原子性,复合操作仍会有线程安全问题。

四、synchronized的锁机制------同时保证三大特性

原理解释(修正不准确表述):synchronized不是"不让其他线程拷贝方法入栈"(每个线程都有自己的栈帧,方法局部变量是线程私有的),而是通过**对象监视器(monitor)**实现互斥访问,同一时间只有一个线程能进入被同一个锁保护的同步代码块/方法。

synchronized保证的三大特性

  1. 原子性:被保护的代码块同一时间只有一个线程执行,"读-改-写"复合操作变成原子操作,不会被打断。
  2. 可见性:线程释放锁前会将工作内存的所有修改刷新到主内存;线程获取锁时会清空工作内存,从主内存重新读取共享变量,完美保证写后读。
  3. 有序性:禁止锁内的指令重排序到锁外,保证代码执行顺序符合预期。

正确示例(结果稳定为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()+1set(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生效。

八、并发安全的核心原则

原理解释(补充扩展)

多进程、多线程、跨服务器并发操作计算准确的核心原则确实是写后读,且与语言无关,但完整的并发安全需要同时满足三大要素:

  1. 原子性:复合操作不可分割(synchronized/Lock)
  2. 可见性:一个线程的修改对其他线程可见(volatile/synchronized/Lock)
  3. 有序性:指令执行顺序符合预期(volatile/synchronized/Lock)

分布式场景扩展:跨服务器并发还需要解决分布式锁、数据一致性(CAP定理、BASE理论)、分布式事务等问题,但"写后读"的核心思想依然适用------任何修改完成后,后续读取必须能看到最新结果,这是所有并发系统正确性的基础。


总结

新手学习并发编程最容易陷入的误区是"以为加了volatile就线程安全"或者"每个方法加锁就万事大吉"。记住两个核心:

  1. volatile只保证可见性,不保证原子性,复合操作必须用锁
  2. 锁的粒度必须覆盖整个原子操作序列,否则等于没加锁

理解了这两点,就能解决90%以上的基础多线程安全问题。

相关推荐
雪度娃娃1 小时前
转向现代C++——优先选用限定作用域的枚举型别,而非不限作用域的枚举型别
java·jvm·c++
不是光头 强1 小时前
Java 后端实战进阶:从踩坑到架构的系统化笔记
java·笔记·架构
HMS工业网络1 小时前
STP、RSTP到N-Ring的演进之路
服务器·开发语言·php
ID_180079054731 小时前
企业级淘宝评论 API最简说明,JSON 返回示例
java·服务器·前端
Plan-C-1 小时前
二叉树的遍历
java·数据结构·算法
历程里程碑2 小时前
54 深入解析poll多路复用技术
java·linux·服务器·开发语言·前端·数据结构·c++
无限进步_2 小时前
【C++】可变参数模板与emplace系列
java·c++·算法
.千余2 小时前
【Linux 】网络基础1
linux·运维·服务器·开发语言·网络·学习
小短腿的代码世界2 小时前
Qt低级网络编程与零拷贝技术在高频交易中的应用:从QTcpSocket到共享内存的全链路优化
开发语言·网络·qt