杰瑞科技汇

Java并发线程,如何高效避免线程安全问题?

目录

  1. 为什么需要并发编程?
  2. Java 线程基础
    • 什么是线程?
    • 线程 vs. 进程
    • 如何创建和启动线程?
    • 线程的生命周期
  3. 线程的核心问题:可见性与原子性
    • 可见性
    • 原子性
    • 有序性
  4. Java 并发解决方案
    • synchronized 关键字
    • volatile 关键字
    • java.util.concurrent.atomic
  5. 高级并发工具
    • 线程池 (java.util.concurrent.ExecutorService)
    • 锁 (java.util.concurrent.locks.Lock)
    • 并发集合 (java.util.concurrent.ConcurrentHashMap, CopyOnWriteArrayList 等)
    • 同步工具类 (CountDownLatch, CyclicBarrier, Semaphore 等)
  6. 线程安全与最佳实践

为什么需要并发编程?

  • 提高 CPU 利用率:现代 CPU 都是多核的,单线程程序无法充分利用所有核心,导致资源浪费,并发编程可以让多个线程在不同的核心上同时执行任务,从而提高程序的整体吞吐量。
  • 提升程序响应速度:对于 I/O 密集型任务(如网络请求、文件读写),使用多线程可以在一个线程等待 I/O 的同时,让其他线程继续处理任务,避免整个程序被阻塞。
  • 简化复杂模型:某些问题天然具有并行性,比如一个大型计算任务可以拆分成多个小任务并行处理。

Java 线程基础

什么是线程?

线程是 进程 中的一个执行单元,是 CPU 调度和分派的基本单位,一个进程可以包含多个线程,它们共享进程的内存空间和系统资源。

Java并发线程,如何高效避免线程安全问题?-图1
(图片来源网络,侵删)

线程 vs. 进程

特性 进程 线程
资源 拥有独立的内存地址空间、文件句柄等。 共享所属进程的资源(内存、文件等)。
开销 创建、销毁、切换开销大。 创建、销毁、切换开销小。
通信 复杂,需要进程间通信。 简单,共享内存即可直接通信。
健壮性 一个进程崩溃,不影响其他进程。 一个线程崩溃(如导致内存错误),会导致整个进程崩溃。

如何创建和启动线程?

Java 提供了三种创建线程的方式:

继承 Thread

class MyThread extends Thread {
    @Override
    public void run() {
        // 线程要执行的代码
        System.out.println("Thread is running: " + Thread.currentThread().getName());
    }
}
public class Main {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        t1.start(); // 启动线程,调用 run() 方法
    }
}

缺点:Java 是单继承的,继承了 Thread 类就无法再继承其他类。

实现 Runnable 接口(推荐)

Java并发线程,如何高效避免线程安全问题?-图2
(图片来源网络,侵删)
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Thread is running: " + Thread.currentThread().getName());
    }
}
public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread t1 = new Thread(myRunnable);
        t1.start();
    }
}

优点

  1. 避免了单继承的限制。
  2. 将线程任务(run 方法的逻辑)与线程本身(Thread 对象)分离,更符合面向设计原则。

实现 Callable 接口(有返回值) CallableRunnable 的增强版,它的 call() 方法可以有返回值,并且可以抛出异常。

import java.util.concurrent.*;
class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        System.out.println("Callable is running.");
        return 123; // 可以返回一个结果
    }
}
public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyCallable myCallable = new MyCallable();
        // 使用 FutureTask 来包装 Callable
        FutureTask<Integer> futureTask = new FutureTask<>(myCallable);
        Thread t1 = new Thread(futureTask);
        t1.start();
        // 获取线程执行结果
        Integer result = futureTask.get(); // get() 方法会阻塞,直到任务完成
        System.out.println("Result from Callable: " + result);
    }
}

线程的生命周期

一个线程从创建到销毁,会经历以下状态:

  1. NEW (新建):线程被创建,但尚未调用 start() 方法。
  2. RUNNABLE (可运行):调用了 start() 方法,此时线程可能正在运行,也可能在等待操作系统分配 CPU 时间片,在 Java API 中,它将 Ready (就绪) 和 Running (运行中) 两种状态合并。
  3. BLOCKED (阻塞):线程等待获取一个排他锁,以便进入同步块/同步方法,线程 A 已经锁住了对象,线程 B 也想进入被该对象锁住的同步块,此时线程 B 就会进入 BLOCKED 状态。
  4. WAITING (等待):线程等待另一个线程来执行一个特定的操作,调用了 Object.wait(), Thread.join()LockSupport.park(),这种状态会一直等待,直到其他线程显式地唤醒它。
  5. TIMED_WAITING (超时等待):和 WAITING 类似,但它可以在指定的时间后自动醒来,调用了 Thread.sleep(long), Object.wait(long), Thread.join(long) 等。
  6. TERMINATED (终止):线程已经执行完毕或因异常而退出。

状态转换图示NEW -> start() -> RUNNABLE -> (获得 CPU) -> RUNNING -> (失去 CPU/主动让出) -> RUNNABLE RUNNABLE -> (获取锁失败) -> BLOCKED -> (获得锁) -> RUNNABLE RUNNABLE -> (调用 wait()/join() 等) -> WAITING / TIMED_WAITING -> (被唤醒/超时) -> RUNNABLE RUNNABLE -> (执行结束) -> TERMINATED

Java并发线程,如何高效避免线程安全问题?-图3
(图片来源网络,侵删)

线程的核心问题

当多个线程共享数据时,会引发三大问题:

可见性

问题:一个线程对共享变量的修改,对其他线程不可见。 原因:每个线程都有自己的工作内存(高速缓存),变量从主内存加载到工作内存,一个线程修改了变量,只是修改了自己工作内存中的副本,如果没有及时写回主内存,其他线程就看不到这个修改。 解决方案

  • synchronized:在解锁时,会将工作内存中的变量刷新到主内存;在加锁时,会清空工作内存,从主内存重新加载变量。
  • volatile:保证了每次读取变量都是从主内存加载,每次写入变量都会立即刷新到主内存。

原子性

问题:一个或多个操作,要么全部执行且执行的过程不会被任何因素打断,要么就都不执行。 经典案例i++ 操作,它不是一个原子操作,包含三个步骤:1) 读取 i 的值;2) i 的值加 1;3) 将新值写回 i,在多线程环境下,这三步可能被交叉执行,导致结果错误。 解决方案

  • synchronized:通过加锁,确保同一时间只有一个线程能进入临界区,保证了代码块的原子性。
  • java.util.concurrent.atomic 包:使用 CAS (Compare-And-Swap) 机制来实现原子操作,性能通常比 synchronized 更高。

有序性

问题:指令的执行顺序可能与代码编写的顺序不一致。 原因:为了优化性能,处理器和编译器可能会对指令进行重排序。 解决方案

  • volatile:禁止指令重排序,保证了代码的执行顺序。
  • synchronized:一个变量在同一个时刻只允许一条线程对其进行 lock 操作,这使得持有同一个锁的两个同步块只能串行地进入,从而保证了有序性。

Java 并发解决方案

synchronized 关键字

synchronized 是 Java 内置的重量级锁,是实现线程安全最基本、最常用的工具。

使用方式

  1. 修饰实例方法:锁是当前对象实例 (this)。
    public synchronized void instanceMethod() { ... }
  2. 修饰静态方法:锁是当前类的 Class 对象。
    public static synchronized void staticMethod() { ... }
  3. 修饰代码块:可以指定锁对象,更加灵活。
    public void someMethod() {
        synchronized (this) { // 锁是 this
            // 代码块
        }
    }
    public void someMethod() {
        synchronized (MyClass.class) { // 锁是 MyClass 的 Class 对象
            // 代码块
        }
    }

volatile 关键字

volatile 是一个轻量级的同步机制,它保证了变量的 可见性禁止指令重排序,但不保证 原子性

适用场景

  • 状态标志位:一个线程修改标志位,其他线程读取。

    private volatile boolean flag = false;
  • 双重检查锁定 实现 singleton 模式(DCL)。

    public class Singleton {
        private static volatile Singleton instance;
        private Singleton() {}
        public static Singleton getInstance() {
            if (instance == null) { // 第一次检查
                synchronized (Singleton.class) {
                    if (instance == null) { // 第二次检查
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }

java.util.concurrent.atomic

该包提供了一系列原子变量类,如 AtomicInteger, AtomicLong, AtomicReference 等,它们内部使用 CAS 算法,在不使用锁的情况下实现了原子操作,性能通常很高。

示例

import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
    private AtomicInteger count = new AtomicInteger(0);
    public void increment() {
        // 原子地执行加 1 操作
        count.incrementAndGet();
    }
    public int getCount() {
        return count.get();
    }
}

高级并发工具

线程池 (java.util.concurrent.ExecutorService)

频繁地创建和销毁线程是非常消耗资源的,线程池可以复用已创建的线程,减少开销,并控制最大并发数。

核心组件

  • Executor:顶层接口。
  • ExecutorService:扩展了 Executor,添加了生命周期管理方法(shutdown(), submit() 等)。
  • ThreadPoolExecutor:最核心的线程池实现类。
  • Executors:一个工厂类,提供了快速创建线程池的便捷方法。

常用线程池

  • newFixedThreadPool(int nThreads):创建一个固定大小的线程池。
  • newCachedThreadPool():创建一个可缓存的线程池,如果线程池大小超过了处理任务所需的线程,那么就会回收部分空闲线程。
  • newSingleThreadExecutor():创建一个单线程的线程池,它只会用唯一的工作线程来执行任务。

使用示例

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建一个固定大小为 2 的线程池
        ExecutorService executor = Executors.newFixedThreadPool(2);
        for (int i = 0; i < 5; i++) {
            final int taskId = i;
            executor.execute(() -> {
                System.out.println("Task " + taskId + " is running by " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        // 关闭线程池
        executor.shutdown();
    }
}

锁 (java.util.concurrent.locks.Lock)

Lock 接口提供了比 synchronized 更广泛的锁定操作,最常用的实现是 ReentrantLock

ReentrantLock vs. synchronized

  • 功能ReentrantLock 提供了公平锁、可中断的锁尝试、尝试锁定(tryLock())等高级功能。
  • 使用synchronized 是 JVM 内置的,自动释放锁。ReentrantLock 需要手动加锁 (lock()) 和解锁 (unlock()),通常在 finally 块中解锁,以确保锁一定会被释放。
  • 性能:在竞争不激烈时,ReentrantLock 性能优于 synchronized;在竞争激烈时,两者性能差不多。

示例

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
    private final Lock lock = new ReentrantLock();
    public void perform() {
        lock.lock(); // 加锁
        try {
            // 临界区代码
            System.out.println("Performing a task in a thread-safe way.");
        } finally {
            lock.unlock(); // 解锁
        }
    }
}

并发集合

Java 提供了线程安全的集合类,位于 java.util.concurrent 包下。

  • ConcurrentHashMap:线程安全的 HashMap,它使用分段锁(在 Java 8 中优化为 CAS + synchronized)来保证高并发下的性能。
  • CopyOnWriteArrayList:线程安全的 ArrayList,它的所有写操作(add, set, remove)都会在底层数组的副本上进行,写完后再将引用指向新数组,读操作则不加锁,性能很高。适用于读多写少的场景
  • BlockingQueue:阻塞队列,当队列为空时,获取元素的线程会阻塞;当队列满时,添加元素的线程会阻塞,常用于生产者-消费者模型。

同步工具类

  • CountDownLatch:倒计时门闩,允许一个或多个线程等待其他一组线程完成操作。
    // 等待 3 个任务线程执行完毕
    CountDownLatch latch = new CountDownLatch(3);
    // 在每个任务线程完成时调用 latch.countDown()
    // 在主线程中调用 latch.await(),会阻塞,直到 countDown() 被调用 3 次
  • CyclicBarrier:循环屏障,让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障,所有线程才会继续执行,可以重复使用。
    // 等待 4 个线程都到达屏障
    CyclicBarrier barrier = new CyclicBarrier(4);
    // 每个线程执行到 barrier.await() 时会暂停
    // 当第 4 个线程到达时,所有线程同时被唤醒
  • Semaphore:信号量,用于控制同时访问特定资源的线程数量。
    // 允许最多 3 个线程同时访问
    Semaphore semaphore = new Semaphore(3);
    // 在访问资源前,调用 semaphore.acquire()
    // 在访问资源后,调用 semaphore.release()

线程安全与最佳实践

  1. 优先使用并发工具:优先使用 java.util.concurrent 包中的工具,它们经过了充分测试,性能和可靠性都很好。
  2. 尽量减少同步范围:同步块(synchronizedlock)内的代码应尽可能少,以减少锁竞争,提高并发度。
  3. 避免在同步块内调用外部方法:这可能导致死锁或性能问题(调用了另一个对象的同步方法)。
  4. 注意死锁:当两个或多个线程互相等待对方释放锁时,就会发生死锁,避免死锁的策略:
    • 按照固定的顺序获取锁。
    • 尽量使用 tryLock() 而不是 lock()
    • 使用 LocklockInterruptibly() 方法,允许线程在等待时被中断。
  5. 理解 volatile 的局限性volatile 不能保证复合操作的原子性,不要用它来替代 synchronizedAtomic 类来处理 i++ 这样的操作。
  6. 优先使用线程池:避免手动创建线程,使用线程池来管理线程的生命周期和资源。
分享:
扫描分享到社交APP
上一篇
下一篇