并发编程(二)

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

原理解释(通俗版)

子线程看到的主线程局部变量相当于"加了final",所以不能修改基本数据类型的值,只能使用引用数据类型;final的引用数据类型不能改变指向,跨线程操作只要不改变指向就可以修改对象内部状态。

修正与深度扩展

准确来说,Java中匿名内部类 (包括Thread子类、Runnable实现类)访问外部方法的局部变量时,该变量必须是finaleffectively 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++不是原子操作,它包含三个独立步骤:

  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原则

  • 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() + 1set(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++)操作,看似简单但实际包含读取-修改-写入三个步骤

  • 示例代码:

    java 复制代码
    volatile 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);
    }
  • 正确做法:确保锁的范围覆盖整个原子操作序列

  1. 基础阶段

    • 深入理解上述两个核心误区
    • 掌握synchronized关键字的使用场景和限制
    • 理解happens-before原则
  2. 进阶内容(在掌握基础后):

    • Lock接口及其实现类(ReentrantLock等)
    • AQS(AbstractQueuedSynchronizer)框架
    • 并发容器(ConcurrentHashMap、CopyOnWriteArrayList等)
    • 线程池(ThreadPoolExecutor)及其配置参数
    • 并发设计模式(如生产者-消费者模式)

理解并避免这两个基础误区,就能解决90%以上的初级多线程安全问题。随着经验积累,再逐步掌握更高级的并发编程技术。

相关推荐
weixin_471383035 小时前
统一缩放单位基础(px、em、rem)
开发语言·javascript·ecmascript
yqcoder5 小时前
数据劫持的双雄:深入解析 Object.defineProperty 与 Proxy
开发语言·前端·javascript
qingfeng154155 小时前
企业微信 API 自动化开发指南:从消息回调到智能运营实战
java·开发语言·python·自动化·企业微信
jonyleek6 小时前
性能就是生命线?规则引擎如何支撑实时决策
java·开发语言·数据库
ZFSS6 小时前
Midjourney Shorten API 的集成与使用
java·前端·数据库·人工智能·ai·midjourney·ai编程
AI科技星6 小时前
第二章 平行素数对网格:矩形→等腰梯形拓扑变换(完整公理终稿)
c语言·开发语言·线性代数·算法·量子计算·agi
宇明一不急6 小时前
go 链表 (标准库实现)
开发语言·链表·golang
前端若水6 小时前
【无标题】
java·人工智能·python·机器学习