Thread
简介
Java 的多线程编程是一个非常强大的部分 😎,它使得我们能过在一个程序中并发的执行多个任务。合理使用多线程,可以提高 Java 应用程序的性能,尤其是在处理 I/O 操作或计算密集型任务时。
多线程
以下是一些在 Java 中实现多线程的主要方式和相关概念:
- 继承 Thread 类
通过继承 Thread 类并重写 run()方法,可以创建一个新线程。
1 | class MyThread extends Thread { |
这种方法适合简单的多线程任务。
不推荐用于复杂项目中,因为 Java 只允许单继承,这限制了灵活性。
- 实现 Runnable 接口
实现 Runnable 接口是一种更常见的方式,适合需要实现多个线程类时的需求,它避免了 Java 单继承的限制。
1 | class MyRunnable implements Runnable { |
推荐在大多数场景下使用,特别是当需要将线程与业务逻辑解耦时。
更灵活,适合多继承的类结构,因为可以实现多个接口。
- ExecutorService
ExecutorService 是一个更高级的多线程管理工具,提供线程池的功能,适合需要管理大量线程的情况。
1 | import java.util.concurrent.ExecutorService; |
当需要处理大量线程时,手动管理每个线程的创建、启动、关闭较为复杂。
ExecutorService 提供了一个线程池,可以高效地管理线程的生命周期,适合并发任务或需要复用线程的情况。
- Callable 和 Future
Callable 接口与 Runnable 类似,但它可以返回结果或抛出异常。配合 Future 可以获取线程执行的结果。
1 | import java.util.concurrent.*; |
当需要线程返回结果时,可以使用 Callable 接口,它允许线程任务返回一个值。
配合 Future 对象,可以异步获取线程的执行结果。
线程生命周期
Java 线程的生命周期主要有以下几个状态:
- 新建(NEW):线程对象已创建,但未启动。
- 就绪(READY):线程已启动,正在等待 CPU 调度。
- 运行(RUNNING):线程获得 CPU,正在执行任务。
- 阻塞(BLOCKED):线程因等待锁或资源被阻塞。
- 等待(WAITING):线程在等待某个条件(例如等待另一个线程的通知)。
- 超时等待(TIMED_WAITING):线程在等待一段时间后再进行操作,例如 Thread.sleep()。
- 终止(TERMINATED):线程的任务执行完毕或由于异常终止。
在 java.lang.Thread.State 枚举类中定义了以下几种状态:
- NEW
- RUNNABLE
- BLOCKED
- WAITING
- TIME_WAITING
- TERMAINATED
并没有见到 RUNNING 的状态,这是因为 RUNNABLE 状态已经足够描述线程是否可以执行,且线程调度的具体时机由操作系统控制。RUNNABLE 状态涵盖了 READY 和 RUNNING 这两种情况,将其进一步细分并无太大必要,也无法完全掌控线程在 CPU 上的具体执行情况。这种设计使得线程状态管理更加简洁,同时又能灵活应对大多数并发场景。
守护线程(Daemon Thread)
守护线程(Daemon Thread)是一种在后台运行的线程,通常用于执行辅助性任务,如垃圾回收、日志记录等。当所有的非守护线程(用户线程)都结束时,JVM 将会退出,不管是否有守护线程仍在运行。
使用 setDaemon(true) 方法将线程设置为守护线程,必须在 start() 方法调用之前设置,否则会抛出 IllegalThreadStateException。
1 | public class DaemonThreadExample { |
输出:当主线程结束时,守护线程也会立即终止。
礼让线程
礼让线程是指线程主动放弃 CPU 使用权,将执行机会交给其他线程。Java 中,Thread.yield() 方法用于实现这种功能。
当一个线程调用 yield() 方法时,它会尝试让出 CPU,回到就绪状态,但它不能保证当前正在运行的线程会立刻停止运行。
操作系统会根据调度算法选择其他处于就绪状态的线程执行。如果没有其他线程或其他线程的优先级较低,当前线程可能会再次执行。
1 | public class YieldExample { |
插入线程
插入线程是指一个线程等待另一个线程执行完成后再继续执行。Java 提供了 Thread.join() 方法来实现这种功能。
当线程调用另一个线程的 join() 方法时,当前线程会进入等待状态,直到被调用的线程执行完毕为止。
也可以指定超时时间,使用 join(long millis) 让当前线程等待指定的时间后继续执行,不管目标线程是否已经完成。
1 | public class JoinExample { |
输出:thread1 会先执行完成,主线程才会继续执行。
如果不使用 join(),则主线程与其他线程可能会并行执行。
线程优先级
Java 线程可以通过设置优先级来建议操作系统调度器决定哪个线程应该优先运行。线程优先级是一个整数,取值范围从 1 到 10,默认优先级为 5。通过 setPriority(int newPriority) 方法可以设置线程的优先级。
1 | public class PriorityExample { |
线程优先级只是给操作系统的一个建议,线程的实际调度还是由操作系统决定。因此,在不同的操作系统上,线程优先级的效果可能不同。
不应依赖线程优先级来确保程序的正确性,应将其视为一个优化工具。
线程安全
多线程程序中一个关键问题就是线程安全,即多个线程并发访问共享资源,不管运行时环境采用何种调度方式或者这些进程将如何交替进行,并且在主调代码中不需要任何额外的同步和协作,这个类都能表现出正常的行为,那么就称这个类是线程安全的。
如何保证一个类是线程安全的,可以从以下三个特性出发,分别有以下手段:
- 原子性(Atomicity):单个或多个操作是要么全部执行,要么都不执行,提供了互斥访问,同一时刻只能有一个线程来对它进行操作。
- 可见性(Visibility):一个线程对主内存的修改可以及时的被其他线程观察到。
- 有序性(Ordering):一个线程观察其他指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序。
原子性(Atomicity):
- Atomic 包
- AtomicInteger、AtomicLong、AtomicBoolean:提供了对 int、long、boolean 类型的原子操作。
- AtomicReference:提供对任意对象的原子引用操作,适合于需要保证线程安全的对象引用。
- AtomicStampReference:解决 CAS 的 ABA 问题。
- synchronized
- 修饰代码块:对代码块上锁,锁住的是指定对象。
- 修饰方法:对整个方法上锁,锁住的是调用的对象。
- 修饰静态方法:对整个静态方法上锁,锁住的是这个类。
- 锁
- synchronized:内置锁,简单易用。
- ReentrantLock:可重入锁,灵活控制加锁与解锁,支持公平性、可重入性和可中断性。
- ReadWriteLock:读写锁,允许多线程并发读,写操作独占锁。
- StampedLock:支持乐观读锁,适合读多写少的场景。
- Condition:与 ReentrantLock 搭配使用,实现复杂的等待与通知机制。
- Semaphore:信号量,用于控制访问资源的线程数量。
对比:
- atomic 包 提供了高效的无锁机制,适合简单的状态管理和计数器,但仅适用于单一变量的原子性操作,不能处理复杂的多变量同步。
- synchronized 是最简单的锁机制,适用于需要确保多个线程对共享资源的访问同步的场景。其性能在现代 JVM 中得到了优化,但在复杂应用中可能不够灵活。
- Lock 提供了更灵活的锁控制,适用于需要定制锁行为或复杂的同步场景。相比 synchronized,Lock 可以提高性能,但需要开发者手动管理锁的释放,增加了使用的复杂性。
可见性(Visibility):
- volatile:volatile 是 Java 中提供的轻量级同步机制,确保对变量的读写操作具有可见性。使用 volatile 修饰的变量,线程对该变量的修改会立即更新到主内存,其他线程读取该变量时也会直接从主内存中获取最新的值,而不是使用线程本地缓存。
- synchronized:synchronized 也可以确保可见性。在一个线程进入 synchronized 块之前,它会从主内存中获取所有共享变量的最新值;在退出 synchronized 块时,它会把对共享变量的修改写回主内存。因此,在 synchronized 块内进行的操作是对其他线程可见的。
- final:当一个字段被声明为 final 时,Java 确保它在构造器中初始化完成后,其他线程能够看到这个字段的正确值。这意味着对象一旦构造完成,所有线程都能够看见 final 字段的值。
有序性(Ordering):
- volatile 关键字:除了保证可见性,volatile 还可以保证一定的有序性。对于 volatile 变量,JMM 保证对它的写操作会在它后面的读操作之前完成,即禁止了特定情况下的指令重排序。
- synchronized 块:synchronized 不仅可以保证可见性,也可以保证有序性。在一个线程持有 synchronized 锁的期间,Java 会保证线程之间的操作按顺序执行,禁止指令重排序。
死锁
死锁是指两个或多个线程互相等待对方持有的资源,导致任务无法进行。常见的死锁场景是线程 A 锁定资源 1,同时等待资源 2,而线程 B 锁定资源 2,等待资源 1。
避免死锁的方法:
- 尽量减少锁的使用。
- 遵循一致的资源请求顺序,防止循环等待。
线程通信
这三个方法是 Java 内置的线程通信机制,定义在 Object 类中,因为每个对象都可以作为锁来控制线程的同步。
- wait():让当前线程等待,并释放当前对象的锁,直到其他线程调用 notify() 或 notifyAll() 使其唤醒。
- notify():唤醒在当前对象监视器上等待的一个线程。
- notifyAll():唤醒在当前对象监视器上等待的所有线程。
这三者必须在同步代码块或者同步方法中使用,否则会抛出 IllegalMonitorStateException 异常。
示例:生产者-消费者模型
1 | public class SharedResource { |
在这个例子中,生产者线程和消费者线程通过 wait() 和 notify() 方法进行通信。生产者在缓冲区满时等待消费者消费,消费者在缓冲区为空时等待生产者生产。
JUC
JUC 是 Java 并发工具包(Java Util Concurrent)的缩写,它是 Java 5 中引入的一个强大的并发框架,旨在简化和增强 Java 应用中的并发编程。
- 线程池
JUC 提供了丰富的线程池实现,使用线程池可以避免手动管理线程的创建和销毁,提升系统性能。线程池通过 Executors 工厂类来创建,主要包括以下几种:
- FixedThreadPool:一个固定数量的线程池。
- CachedThreadPool:一个可根据需要创建新线程的线程池,但如果线程闲置超过 60 秒则会被终止并移除。
- SingleThreadExecutor:只有一个线程的线程池,确保任务按顺序执行。
- ScheduledThreadPool:用于定时任务执行或周期性任务调度的线程池。
自定义线程池 ThreadPoolExecutor:
1 | public ThreadPoolExecutor( |
参数说明:
corePoolSize(核心线程数):
- 核心线程是线程池中的基本线程数,在没有超时机制时,它们会一直存在,不会被回收,即使处于空闲状态。
- 当提交一个任务时,如果当前线程数小于核心线程数,即使线程池中有空闲线程,也会创建新的线程来执行任务。
maximumPoolSize(最大线程数):
- 最大线程数是线程池能容纳的最大线程数量,当核心线程全部繁忙,且任务队列已满时,线程池会创建新的线程,直到达到 maximumPoolSize。
keepAliveTime(空闲线程存活时间):
- 当线程数超过 corePoolSize,多余的空闲线程将会在超过 keepAliveTime 后被终止。这个值只对超过核心线程数的线程起作用。
unit(时间单位):
- keepAliveTime 的时间单位,如 TimeUnit.SECONDS、TimeUnit.MILLISECONDS 等。
workQueue(任务队列):
一个阻塞队列,用来存放等待执行的任务。常用的队列类型有:- LinkedBlockingQueue:一个无界队列,可以存储任意数量的任务。
- ArrayBlockingQueue:一个有界队列,任务数量超过其容量后会阻塞新的任务提交。
- SynchronousQueue:每提交一个任务都需要立刻有线程执行,否则会阻塞。
threadFactory(线程工厂):
- 线程工厂用于创建新线程,一般使用默认的 Executors.defaultThreadFactory(),但也可以自定义以便对线程命名、设置优先级等。
handler(拒绝策略):
当任务队列满且线程数达到最大线程数时,线程池无法处理新的任务,此时需要采取拒绝策略。ThreadPoolExecutor 提供了几种内置的拒绝策略:- AbortPolicy(默认):直接抛出 RejectedExecutionException 异常。
- CallerRunsPolicy:调用线程执行任务,即提交任务的线程自己执行该任务。
- DiscardPolicy:直接丢弃无法处理的任务,不抛出异常。
- DiscardOldestPolicy:丢弃队列中最旧的任务,并尝试重新提交新的任务。
同步工具类
JUC 中提供了多个同步工具类来协调多个线程之间的执行顺序。
- CountDownLatch:允许一个或多个线程等待其他线程完成任务。常用于多个子线程完成任务后再继续主线程。
- CyclicBarrier:类似于 CountDownLatch,但它允许一组线程相互等待,所有线程都到达屏障点后一起继续执行。可以在并发任务的阶段性同步时使用。
- Semaphore:信号量,用来限制访问共享资源的线程数量。常用于控制并发量,比如限制对某些资源的并发访问数量。
- Exchanger:用于在线程之间交换数据。两个线程到达同步点后可以交换数据。
- 并发集合
JUC 提供了线程安全的集合类,用于解决并发环境下对集合进行修改时的线程安全问题。常用的并发集合有:
- ConcurrentHashMap:线程安全的哈希表,允许多个线程并发地访问和修改。
- CopyOnWriteArrayList:适用于读多写少的场景,每次写操作都会复制整个列表。
- ConcurrentLinkedQueue:高效的非阻塞并发队列,适用于多线程环境下的队列操作。
- BlockingQueue:支持阻塞操作的队列,在生产者-消费者模式中非常有用。实现类有 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 等。
- 原子类
JUC 提供了一系列的原子操作类,它们保证在多线程环境下对基本数据类型的操作是原子的,避免了显式的同步开销。这些类主要包括:
- AtomicInteger、AtomicLong、AtomicBoolean:对基本类型(如 int、long、boolean)的原子操作。
- AtomicReference:对对象引用进行原子操作。
- AtomicStampedReference:解决 CAS(Compare-And-Swap)中的 ABA 问题。
线程池最佳线程数
确定线程池的最佳线程数需要根据任务的特性和硬件资源来调整。
任务类型主要分为两类:
- CPU 密集型任务:这种任务主要消耗 CPU 资源,如复杂的计算、数据处理等。
- I/O 密集型任务:这种任务主要依赖 I/O 操作,如文件读写、网络请求等,CPU 在等待 I/O 完成时通常会处于空闲状态。
硬件资源
主要是服务器的 CPU 核心数 和 I/O 带宽。合理配置线程数可以最大化利用这些资源。
最佳线程数计算公式
- CPU 密集型任务
1 | 线程数 = CPU 核心数 + 1 |
这里加 1 是为了保证即使有某个线程发生上下文切换或者等待时,仍有线程可以继续执行。
- I/O 密集型任务
1 | 线程数 = CPU 核心数 * 期望CPU利用率 * (1 + 任务等待时间/任务计算时间) |
任务的等待时间与计算时间的比值越大,说明任务越依赖 I/O,线程数就应该相应地增加。
结语
这里只是浅尝即止的讨论java的多线程技术,在后续的java并发编程一系列中会深入研究java的并发编程技术。