第4章

🔒 线程安全问题

深入理解多线程环境下的安全隐患,掌握线程安全问题的识别和解决方法

学习目标

什么是线程安全问题

线程安全问题是指在多线程环境下,由于多个线程同时访问共享资源而导致的数据不一致、程序行为不可预期的问题。这些问题往往难以重现和调试,是并发编程中最具挑战性的部分。

核心问题

线程安全问题的根本原因在于:多个线程对共享数据的并发访问缺乏适当的同步机制,导致数据竞争和不一致状态。

线程安全问题的特征

不确定性
相同的代码在不同的执行环境下可能产生不同的结果,难以预测和重现。
时序依赖
程序的正确性依赖于线程的执行时序,时序的微小变化可能导致错误。
隐蔽性
问题可能在生产环境中偶发出现,在测试环境中难以发现和调试。

竞态条件(Race Condition)

竞态条件是指程序的正确性依赖于多个线程的相对执行时序。当多个线程同时访问和修改共享数据时,最终结果取决于线程调度的时序,这种不确定性就是竞态条件。

经典案例:计数器问题

public class UnsafeCounter {
    private int count = 0;
    
    public void increment() {
        count++;  // 这不是原子操作!
    }
    
    public int getCount() {
        return count;
    }
}

// 测试代码
public class RaceConditionDemo {
    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter counter = new UnsafeCounter();
        int threadCount = 10;
        int incrementsPerThread = 1000;
        
        Thread[] threads = new Thread[threadCount];
        
        for (int i = 0; i < threadCount; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < incrementsPerThread; j++) {
                    counter.increment();
                }
            });
        }
        
        // 启动所有线程
        for (Thread thread : threads) {
            thread.start();
        }
        
        // 等待所有线程完成
        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("期望结果: " + (threadCount * incrementsPerThread));
        System.out.println("实际结果: " + counter.getCount());
        // 实际结果通常小于期望结果
    }
}
为什么会出现竞态条件?

count++ 操作实际上包含三个步骤:

  • 读取count的当前值
  • 将值加1
  • 将新值写回count

当多个线程同时执行这三个步骤时,可能会相互干扰,导致数据丢失。

银行转账的竞态条件

public class BankAccount {
    private double balance;
    
    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }
    
    public void withdraw(double amount) {
        if (balance >= amount) {  // 检查余额
            // 模拟处理时间
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            balance -= amount;  // 扣除金额
            System.out.println("取款 " + amount + ",余额: " + balance);
        } else {
            System.out.println("余额不足,无法取款 " + amount);
        }
    }
    
    public double getBalance() {
        return balance;
    }
}

// 测试并发取款
public class BankTransferDemo {
    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount(1000.0);
        
        // 创建多个线程同时取款
        Thread[] threads = new Thread[5];
        for (int i = 0; i < 5; i++) {
            threads[i] = new Thread(() -> {
                account.withdraw(300.0);
            });
        }
        
        for (Thread thread : threads) {
            thread.start();
        }
        
        for (Thread thread : threads) {
            thread.join();
        }
        
        System.out.println("最终余额: " + account.getBalance());
        // 可能出现负数余额!
    }
}

原子性问题

原子性是指一个操作要么完全执行,要么完全不执行,不能被其他线程中断。在多线程环境下,非原子操作可能被其他线程干扰,导致数据不一致。

什么操作不是原子的?

复合操作
  • i++ 和 ++i
  • i += 5
  • 复合赋值操作
检查后执行
  • if (condition) { action(); }
  • 懒加载模式
  • 单例模式的双重检查
64位数据操作
  • long类型的读写
  • double类型的读写
  • 在32位JVM上可能不是原子的

原子性问题示例

public class AtomicityProblem {
    private long value = 0;
    
    public void setValue(long newValue) {
        this.value = newValue;  // 在32位JVM上可能不是原子的
    }
    
    public long getValue() {
        return value;  // 在32位JVM上可能不是原子的
    }
}

// 可能读取到部分更新的值
public class PartialUpdateDemo {
    public static void main(String[] args) {
        AtomicityProblem problem = new AtomicityProblem();
        
        // 写线程
        Thread writer = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                problem.setValue(0x0123456789ABCDEFL);
                problem.setValue(0xFEDCBA9876543210L);
            }
        });
        
        // 读线程
        Thread reader = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                long value = problem.getValue();
                if (value != 0x0123456789ABCDEFL && 
                    value != 0xFEDCBA9876543210L && 
                    value != 0) {
                    System.out.println("检测到部分更新: " + 
                        Long.toHexString(value));
                }
            }
        });
        
        writer.start();
        reader.start();
    }
}

可见性问题

可见性问题是指一个线程对共享变量的修改,其他线程不能立即看到。这是由于现代计算机的内存模型和CPU缓存机制导致的。

可见性问题的原因

CPU缓存
每个CPU核心都有自己的缓存,变量的修改可能只存在于某个CPU的缓存中,没有同步到主内存。
编译器优化
编译器可能会对代码进行优化,将变量缓存在寄存器中,导致其他线程看不到最新值。
内存模型
Java内存模型允许线程在本地内存中缓存共享变量的副本,导致可见性问题。

可见性问题示例

public class VisibilityProblem {
    private boolean flag = false;
    private int counter = 0;
    
    public void writer() {
        counter = 42;
        flag = true;  // 设置标志位
    }
    
    public void reader() {
        if (flag) {  // 可能永远看不到flag的变化
            System.out.println("Counter: " + counter);
            // counter的值可能不是42!
        }
    }
}

// 演示可见性问题
public class VisibilityDemo {
    public static void main(String[] args) throws InterruptedException {
        VisibilityProblem problem = new VisibilityProblem();
        
        // 读线程
        Thread reader = new Thread(() -> {
            while (true) {
                problem.reader();
            }
        });
        
        reader.start();
        Thread.sleep(1000);
        
        // 写线程
        Thread writer = new Thread(() -> {
            problem.writer();
            System.out.println("Writer finished");
        });
        
        writer.start();
        writer.join();
        
        // 读线程可能永远不会输出"Counter: 42"
        Thread.sleep(2000);
        System.exit(0);
    }
}
volatile关键字

使用volatile关键字可以解决可见性问题:

private volatile boolean flag = false;
private volatile int counter = 0;

volatile确保变量的读写直接在主内存中进行,保证了可见性。

有序性问题

有序性问题是指程序执行的顺序可能与代码编写的顺序不一致。这是由于编译器和处理器为了优化性能而进行的指令重排序导致的。

指令重排序的类型

编译器重排序
编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
指令级重排序
处理器采用指令级并行技术,在不存在数据依赖性的情况下改变指令的执行顺序。
内存系统重排序
由于处理器使用缓存和读写缓冲区,加载和存储操作可能在不同的顺序执行。

有序性问题示例

public class ReorderingProblem {
    private int a = 0;
    private boolean flag = false;
    
    public void writer() {
        a = 1;          // 操作1
        flag = true;    // 操作2
        // 由于重排序,操作2可能在操作1之前执行
    }
    
    public void reader() {
        if (flag) {     // 操作3
            int i = a * a;  // 操作4
            // 如果发生重排序,这里的a可能还是0
            System.out.println("a * a = " + i);
        }
    }
}

// 双重检查锁定的有序性问题
public class DoubleCheckedLocking {
    private volatile static DoubleCheckedLocking instance;
    
    public static DoubleCheckedLocking getInstance() {
        if (instance == null) {  // 第一次检查
            synchronized (DoubleCheckedLocking.class) {
                if (instance == null) {  // 第二次检查
                    instance = new DoubleCheckedLocking();
                    // 这里可能发生重排序!
                    // 1. 分配内存空间
                    // 2. 调用构造函数初始化对象
                    // 3. 将instance指向分配的内存地址
                    // 如果2和3重排序,其他线程可能看到未初始化的对象
                }
            }
        }
        return instance;
    }
}
happens-before规则

Java内存模型通过happens-before规则来保证有序性:

  • 程序顺序规则:单线程内,按照程序代码顺序执行
  • 监视器锁规则:unlock操作happens-before后续的lock操作
  • volatile变量规则:volatile写happens-before后续的volatile读
  • 传递性:如果A happens-before B,B happens-before C,则A happens-before C

线程安全的设计原则

设计线程安全的代码需要遵循一些基本原则,这些原则可以帮助我们避免常见的线程安全问题。

核心设计原则

不可变对象

创建不可变的对象,一旦创建就不能修改其状态。

public final class ImmutablePerson {
    private final String name;
    private final int age;
    
    public ImmutablePerson(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String getName() { return name; }
    public int getAge() { return age; }
}
线程封闭

将数据封闭在单个线程中,避免共享。

// ThreadLocal实现线程封闭
public class ThreadLocalExample {
    private static ThreadLocal formatter = 
        ThreadLocal.withInitial(() -> 
            new SimpleDateFormat("yyyy-MM-dd"));
    
    public String formatDate(Date date) {
        return formatter.get().format(date);
    }
}
同步机制

使用适当的同步机制保护共享数据。

public class SafeCounter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}

最佳实践

设计建议
  • 最小化共享:尽量减少线程间的共享数据
  • 使用并发工具类:优先使用java.util.concurrent包中的工具
  • 避免过度同步:同步的粒度要适当,避免性能问题
  • 文档化线程安全性:明确标注类的线程安全级别
  • 测试并发代码:使用压力测试和并发测试工具
上一章:线程生命周期 返回目录 下一章:同步机制