Java 线程(二)

112 阅读10分钟

线程同步与锁机制

在多线程编程中,同步与锁机制是保障数据一致性与线程安全的关键技术。线程同步是一种协调多线程访问共享资源的技术,目的是避免线程间竞争导致的数据不一致问题,下面举个例子:

public class DataInconsistencyDemo {

    // 共享变量
    private static int sharedCounter = 0;
    
    public static void main(String[] args) throws InterruptedException {
        // 创建两个线程
        Thread thread1 = new Thread(new IncrementTask(), "Thread-1");
        Thread thread2 = new Thread(new IncrementTask(), "Thread-2");
        
        // 启动线程
        thread1.start();
        thread2.start();
        
        // 等待线程完成
        thread1.join();
        thread2.join();
        
        // 输出最终结果
        System.out.println("Final counter value: " + sharedCounter);
    }
    
    // 线程任务:对共享变量进行递增操作
    static class IncrementTask implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 100000; i++) {
                sharedCounter++; // 这不是原子操作
            }
            System.out.println(Thread.currentThread().getName() + " finished. Counter: " + sharedCounter);
        }
    }
}

预期结果:两个线程各增加100,000次,最终结果应该是200,000

实际结果:最终结果通常会小于200,000,因为存在数据竞争

sharedCounter++ 不是一个原子操作,它实际上包含三个步骤:

  1. 读取当前值
  2. 增加1
  3. 写回新值

当多个线程同时执行这些步骤时,可能会发生以下情况:

  • 线程A读取值(假设为100)
  • 线程B也读取值(还是100)
  • 线程A增加并写回(101)
  • 线程B增加并写回(101)
  • 结果应该是102,但实际上只增加了1

线程安全问题的根源

在 Java中,造成线程安全问题的根本原因是硬件结构,实际中为了消除 CPU 和主内存之间的硬件速度差,通常会在两者之间设置多级缓存(L1 ~ L3),如下图:

image.png

Java为了适配多级缓存的硬件构造,设计了一套与之对应的内存模型(JMM,Java memory model,包括主内存和工作内存,如下图:

image.png

主内存: 程序中所有的变量都存储在主内存中

工作内存: 每一个线程都有自己的工作内存,线程会将主内存的共享变量读取到自己的工作内存中,然后进行后续操作,最后再将工作内存中的变量写入到主内存

线程对主内存中变量的所有操作(读取、写入)都必须在自己的工作内存中进行,而不能直接操作主内存中的变量。线程之间的工作内存是相互隔离的,变量的传递需要通过主内存来完成

原子性

类似于在数据库事务ACID中原子性(Atomicity)的概念,它是指一个操作是不可分割的,即要么全部执行,要么全部不执行。Java 线程安全中的原子性与数据库事务中的原子性本质是一样的,只是它们应用的上下文和具体实现有所不同

Java 提供了多种方式来保证原子性,比如同步块、锁或者原子类

可见性

可见性是指如果一个线程对共享变量的做出修改操作,其他线程能立刻感知到。在Java中,volatile关键字可以保证变量的可见性

// 线程A
sharedFlag = true; // 可能仅写入CPU缓存,未刷新到主内存

// 线程B
while (!sharedFlag) { ... } // 可能永远看不到线程A的修改

需要注意的是:

被 volatile 修饰的变量不能解决原子性问题,它只能保证可见性禁止指令重排序,但无法保证复合操作的原子性,这是因为volatile无法锁定共享资源

volatile 的核心作用:

  • 可见性:强制线程每次读写变量时直接操作主内存,而非CPU缓存,确保修改对其他线程立即可见
  • 禁止指令重排序:通过插入内存屏障(Memory Barrier)防止编译器和CPU优化重排序

有序性问题

有序性指的是 程序执行的顺序符合代码的预期逻辑顺序,尤其是在多线程环境下,确保指令不会被编译器或 CPU 因优化而重排序(Reordering) ,从而导致意外的结果

为什么需要有序性?

在单线程环境下,编译器和 CPU 可能会对指令进行重排序优化(在不改变程序最终结果的前提下,调整指令执行顺序以提高性能)。但在多线程环境下,这种优化可能导致线程间观察到的执行顺序不一致,从而引发线程安全问题

下面给个示例:

public class Singleton {
    private static Singleton instance;  // 未加 volatile
    
    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查(非同步)
            synchronized (Singleton.class) {       // 加锁
                if (instance == null) {            // 第二次检查(同步)
                    instance = new Singleton();    // 可能发生指令重排序!
                }
            }
        }
        return instance;
    }
}

instance = new Singleton(); 并非原子操作,底层可能分解为:

  1. 分配内存空间
  2. 初始化对象(调用构造函数)
  3. 将引用赋值给 instance

如果 指令被重排序(如 3 → 2),其他线程可能在 instance 被完全初始化前就读取到非 null 但未初始化的对象,导致程序错误

同步方案对比

synchronized 关键字

原理:基于 JVM 实现的监视器锁(Monitor Lock),进入同步代码块前获取锁,退出时释放锁。
适用场景:方法或代码块级别的简单同步

需要注意的是: synchronized(this)和synchronized(.Class)的区别:

  • 锁对象不同:
    • synchronized(this):锁定当前对象实例(非静态方法锁)
      • 锁的是调用该代码块的对象实例
      • 不同对象实例之间不会互相阻塞
    • synchronized(xxx.class) :锁定类的 Class 对象(静态方法锁)
      • 锁的是类的 Class 对象(每个类在 JVM 中只有一个 Class 对象)
      • 对所有该类的实例都有效,会阻塞所有访问该代码的线程
  • 作用范围不同
    • synchronized(this)
      • 只影响同一个实例的多个线程
      • 不同实例的线程可以同时执行同步代码块
    • synchronized(xxx.class)
      • 影响该类的所有实例
      • 所有线程(无论来自哪个实例)都会竞争同一把锁
ReentrantLock(可重入锁)

ReentrantLock 是 Java 并发包 (java.util.concurrent.locks) 中提供的一种可重入的互斥锁,它比传统的 synchronized 关键字提供了更灵活、更强大的锁机制

基本特性:

  1. 可重入性:同一个线程可以多次获取同一把锁而不会死锁
  2. 公平性选择:可以构造公平锁或非公平锁(默认非公平
  3. 锁的获取与释放分离:需要显式调用 lock() 和 unlock()
  4. 可中断的锁获取:提供了可响应中断的锁获取方式
  5. 超时获取锁:可以尝试在一定时间内获取锁

ReentrantLock 是怎么实现可重入的?

它的实现依赖于 AQS(AbstractQueuedSynchronizer) ,并通过 线程持有者(owner) 和 重入计数器(holdCount)  来管理锁的状态

查看 ReentrantLock 类的源码中的tryLock方法可以发现:

final boolean tryLock() {
  Thread current = Thread.currentThread();
  int c = getState();
   if (c == 0) {
       if (compareAndSetState(0, 1)) {
           setExclusiveOwnerThread(current);
           return true;
       }
   } else if (getExclusiveOwnerThread() == current) {
       if (++c < 0) // overflow
           throw new Error("Maximum lock count exceeded");
       setState(c);
       return true;
   }
   return false;
}
  1. 首先会通过getState()方法获取锁的状态
    • = 0:锁未被占用
    • > 0:锁被占用,数值表示 重入次数holdCount
  2. 然后判断锁是否被占用
    1. 如果没有被占用(state = 0),则会通过 CAS 将 state 设置为1,并且将字段 exclusiveOwnerThread设置为当前线程
    2. 如果锁被占用,则会判断是不是当前线程
      1. 如果是当前线程,则会将 state 的值+1并重新赋值给 state
      2. 如果不是当前线程,则会直接返回false表示尝试加锁失败并进入阻塞队列等待

这里的 state 和 exclusiveOwnerThread 字段都不是 ReentrantLock 类本身维护,而是由 AbstractQueuedSynchronizer 类(AQS)维护,ReentrantLock 类中维护了一个抽象静态内部类 Sync 继承了 AbstractQueuedSynchronizer 然后调用 AQS 方法

ReadWriteLock(读写锁)

ReadWriteLock 是 Java 并发包 (java.util.concurrent.locks) 中提供的一种 读写分离锁,它允许多个线程同时读取共享资源,但写操作必须是独占的。这种锁适用于 读多写少 的场景,可以显著提高并发性能。

public interface ReadWriteLock {

    Lock readLock();

    Lock writeLock();
}

通过源码可以看到,ReadWriteLock是一个根接口,只提供了读锁和写锁两个方法(能力)

读锁(共享锁)

  • 可以被多个线程同时持有
  • 只要没有线程持有写锁,多个线程可以同时获取读锁

写锁(独占锁)

  • 同一时间只能由一个线程持有
  • 如果写锁被占用,其他线程(无论是读还是写)都必须等待

Java 提供的 ReadWriteLock 的主要实现是 ReentrantReadWriteLock,它具有以下特性:

  • 可重入:同一个线程可以多次获取读锁或写锁(需对应释放相同次数)

  • 公平性可选

    • 非公平模式(默认) :吞吐量更高,但可能造成线程饥饿
    • 公平模式:按请求顺序分配锁,避免饥饿,但性能较低
  • 锁降级:允许持有写锁的线程获取读锁,然后释放写锁,从而降级为读锁

  • 不支持锁升级:不能直接从读锁升级为写锁(必须先释放所有读锁)

原子类

原子类是 Java 并发包(java.util.concurrent.atomic)提供的一组线程安全的工具类,用于实现 无锁(lock-free)  的线程安全操作。它们基于 CAS(Compare-And-Swap)  机制,比传统的 synchronized ReentrantLock 性能更高,适用于高并发场景

核心特性:

  • 线程安全:无需加锁即可保证操作的原子性

  • 高性能:基于 CAS(Compare-And-Swap)  实现,避免线程阻塞

  • 内存可见性:所有操作遵循 happens-before 规则,确保多线程间的数据一致性

  • 适用场景

    • 计数器(如 AtomicInteger
    • 标志位(如 AtomicBoolean
    • 对象引用更新(如 AtomicReference
    • 数组操作(如 AtomicIntegerArray

CAS

原子类的核心是 CAS,它是一种无锁算法,由 CPU 指令直接支持

public boolean compareAndSet(int expect, int update) {
   if (当前值 == expect) {  // 检查当前值是否等于预期值
       当前值 = update;     // 如果是,更新为新值
       return true;
   }
   return false;

CAS 的三大问题

  • ABA 问题:
    • 线程 A 看到变量从 A → B → A,误以为没变化
    • 解决方案:AtomicStampedReference(带版本号)或者时间戳
  • 循环时间长开销大
    • 如果 CAS 失败,会一直自旋(while 循环),消耗 CPU
  • 只能保证单个变量的原子性
    • 多个变量的原子操作需用 AtomicReference 封装
CountDownLatch

CountDownLatch 是 Java 并发包 (java.util.concurrent) 中的一个同步工具类,它允许 一个或多个线程等待其他线程完成操作,然后再继续执行。它的核心思想是 倒计时计数,适用于 多线程任务协调 的场景

核心概念
  • 初始化计数器CountDownLatch 在创建时需要指定初始计数值(count)
  • 等待线程:调用 await() 的线程会被阻塞,直到计数器归零
  • 计数线程:调用 countDown() 的线程会将计数器减 1,当计数器归零时,所有等待线程被唤醒
使用场景
  • 主线程等待多个子线程完成任务
public class MainThreadWaits {
    public static void main(String[] args) throws InterruptedException {
        int threadCount = 5;
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " 执行任务");
                latch.countDown();  // 任务完成,计数器减 1
            }).start();
        }

        latch.await();  // 主线程阻塞等待所有子线程完成任务
        System.out.println("所有任务完成,主线程继续执行");
    }
}
  • 多个线程等待一个信号后同时开始
public class ThreadsWaitForSignal {
    public static void main(String[] args) throws InterruptedException {
        int threadCount = 3;
        CountDownLatch startSignal = new CountDownLatch(1);  // 初始值为 1

        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                try {
                    startSignal.await();  // 所有线程在此等待
                    System.out.println(Thread.currentThread().getName() + " 开始执行");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }

        Thread.sleep(1000);  // 模拟准备时间
        System.out.println("准备完成,所有线程开始执行!");
        startSignal.countDown();  // 释放所有等待线程
    }
}
底层实现

CountDownLatch 基于 AQS(AbstractQueuedSynchronizer)  实现:

  • 计数器(state) :AQS 的 state 存储当前计数值
  • await() :如果 state != 0,线程进入等待队列
  • countDown()state--,如果 state == 0,唤醒所有等待线程

死锁分析与预防

死锁详解