1、线程的状态
在Java中,线程(Thread)可以处于以下六种状态之一,这些状态在java.lang.Thread.State
枚举中定义:
-
NEW (新建): 线程对象已经被创建,但是还没有调用
start()
方法。此时的线程还没有真正开始执行。 -
RUNNABLE (可运行): 线程已经调用了
start()
方法,可能正在运行,也可能正在等待CPU分配时间片。在这个状态下,线程有资格运行,但是还没有被操作系统选中运行。即将RUNNING
和READY
两个状态统一抽象为RUNNABLE
。 -
BLOCKED (阻塞): 线程正在等待监视器锁(monitor lock),以进入同步区域/方法。一旦其他线程释放了锁,并且该线程获得了锁,其状态可以变回RUNNABLE。
-
WAITING (等待): 线程进入等待状态,因为它在等待其他线程显式地通知唤醒,通常用在调用了如下方法:
Object.wait()
(没有超时时间的)Thread.join()
(没有超时时间的)LockSupport.park()
-
TIMED_WAITING (定时等待): 线程处于定时等待状态,这意味着它在等待另一个线程的通知,或者在指定的睡眠时间内等待,通常出现在调用了一些带有时间参数的方法,例如:
Thread.sleep(long millis)
Object.wait(long timeout)
Thread.join(long millis)
LockSupport.parkNanos(Object blocker, long nanos)
LockSupport.parkUntil(long deadline)
-
TERMINATED (终止): 线程已经执行完了它的任务或者因为异常退出了run方法,线程的运行结束。
以上是Java线程可能出现的状态,Java程序可以通过Thread
类的getState()
方法检查线程的状态。这个状态模型有助于理解和调试多线程Java程序,因为它描述了线程在其生命周期中的各种状态。
对于自动唤醒来说,内部机制通常涉及操作系统的调度器和时间管理系统,确保线程能在适当的时间恢复执行。
2、内存模型
2.1 指令重排
指令重排(Instruction Reordering)是一种性能优化技术,它允许编译器和处理器改变指令的执行顺序,而不会改变单线程程序的执行结果。这种重新排序的目的是为了提高执行效率、更好地利用 CPU 缓存、减少指令延迟和提高并行度。
在 Java 内存模型(Java Memory Model,JMM)中,有两种形式的指令重排:
- 编译器重排:Java 编译器在生成字节码时,可以重新排序指令,以提高性能。
- 运行时重排:JVM 在运行时(特别是即时编译器 JIT)和硬件在执行机器指令时,都可能进行重排。
2.2 顺序一致性
顺序一致性(Sequential Consistency)是一个内存模型概念,它描述了程序执行时操作的顺序。在顺序一致的内存模型中,操作的结果看起来就像是按照程序代码的顺序,一次一个地,按序执行的,不会出现重排序(reordering)现象。这个模型非常简单直观,因为它与我们编写程序时考虑的执行顺序一致。
顺序一致性的主要特点如下:
- 操作顺序:操作结果看起来就像它们是按照程序代码中指定的顺序执行的。
- 即时可见性:单个处理器上的写操作立即对所有其他处理器可见。
2.3 Happens-before
就是一堆规则,代码里的指令命中这些规则,就不会发生指令重排。
2.4 as-if-serial
与顺序一致性同一个概念。
3、volatile
Java的volatile
关键字是Java虚拟机提供的轻量级的同步机制。volatile
变量主要用于确保将变量的更新操作通知到其他线程。当一个字段被声明为volatile
后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作进行重排序。volatile
保证了变量的可见性和部分有序性,但不保证原子性。
3.1 可见性
在没有使用volatile
关键字的情况下,线程可以把变量保留在本地内存中,而不是直接在主内存中进行读写。这就可能会导致一个线程在修改了一个变量后,其他线程看不到更新后的值。
当一个变量被声明为volatile
之后,所有对该变量的读写都会直接在主内存中进行。这意味着当一个线程更新了一个volatile
变量后,这个新值对其他线程来说是立即可见的。同时,当从一个volatile
变量读取值的时候,会从内存中读取,而不是从线程的本地缓存。
3.2 禁止指令重排序
Java内存模型允许编译器和处理器对指令进行重排序,以提高执行性能。但是,当变量被声明为volatile
后,就会禁止与这个变量相关的重排序操作。这就保证了volatile
变量的操作是有序的。
3.3 实现细节
在底层,volatile
的实现依赖于特定硬件的内存屏障指令。对于主流的x86架构,volatile
读操作大致相当于一个无操作指令(no-op),而写操作则会插入一条store barrier
指令,以防止写操作与之前的读写操作进行重排序。而对于读操作,大多数平台在读取volatile
变量后会插入load barrier
指令,以确保不会有指令跑到内存屏障之前进行。
这些内存屏障指令确保了在它们之前的所有内存操作完成后,才执行屏障后面的操作。这正是volatile
如何实现其可见性和有序性保证的基础。
3.4 注意事项
虽然volatile
可以保证变量修改的可见性和有序性,但它并不能保证复合操作的原子性。比如,即使是volatile
变量的自增操作(如volatileVar++
)也不是原子的,因为这实际上是由多条指令完成的(读取-修改-写入)。因此,在这种情况下,还是需要使用synchronized
或java.util.concurrent
里面的原子类。
4、DCL
4.1 单例模式
DCL(DCL 是 "Double-Checked Locking"(双重检验锁)的缩写)
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;
}
}
这个模式看起来非常完美,因为它既保证了只在单例未被创建时进行同步,又避免了每次调用 getInstance()
方法时的同步开销。但在早期 Java 版本中,这个模式是有问题的。
问题在于 instance = new Singleton();
这行代码并不是原子操作,它涉及到下面的步骤:
- 为
Singleton
实例分配内存。 - 调用
Singleton
构造函数来初始化成员字段。 - 将
instance
对象指向分配的内存空间(执行完这步instance
就不再是null
了)。
由于 Java 的编译器允许所谓的 "指令重排"(instruction reordering),以上的执行顺序有可能变成 1-3-2。在多线程环境中,如果线程 A 执行了 1 和 3,还没执行 2,此时线程 B 进行第一次 instance
检查时发现 instance
不是 null
,就返回了一个尚未初始化的对象。
为了解决这个问题,instance
字段必须被声明为 volatile
。在 Java 5 及之后的版本,volatile
的语义加强了,保证了避免指令重排,并且确保读取 volatile
变量时前面的操作都已经完成,写入 volatile
变量时已经将变更对所有线程可见。这确保了 DCL 的正确实施。
另外,现代 JVM 的 synchronized
实现已经非常轻量化,性能已经大幅提高,并且从 Java 6 开始,还可以利用 java.util.concurrent
包下的 AtomicReference
或相关类更简洁高效地实现懒加载单例模式,所以现在使用 DCL 的场景越来越少。
5、synchronized
synchronized
关键字是 Java 中的一个原子特性,它用于实现线程间的同步,以确保在同一时刻只有一个线程可以访问特定资源。了解 synchronized
的工作原理,有助于更有效地编写多线程代码。这里简要介绍 synchronized
的内部工作机制。
5.1 锁的等级
synchronized
可以用于方法(实例方法和静态方法)和代码块上。根据加锁的目标不同,锁可以分为对象锁(监视器锁)和类锁。
- 对象锁:当
synchronized
修饰实例方法或同步代码块时,锁定的是对象实例。 - 类锁:当
synchronized
修饰静态方法或同步代码块并以.class
对象作为锁时,锁定的是整个类。
5.2 锁的获取与释放
- 当线程进入
synchronized
修饰的方法或代码块时,它会自动获取锁。 - 当线程正常退出或抛出异常从而离开这段同步代码时,锁会自动被释放。
这意味着其他线程必须等待直到锁被释放后,才能获得锁进入临界区。
5.3 监视器锁(Monitor)
在 JVM 层面,synchronized
依赖于内部的一个监视器锁(Monitor),这是实现同步的基础。对象在 Java 中都有一个监视器与之关联,当多个线程尝试进入被 synchronized
修饰的代码段时,这些线程会被放置到 Entry Set 或者 Wait Set 中。
- Entry Set:所有尝试获取锁的线程将被放置在 Entry Set,当线程获取锁时,它会从 Entry Set 中移出。
- Wait Set:若线程已经获得了锁,但调用了该对象的
wait()
方法,它将会被放置在 Wait Set 中。当其他线程调用同一个对象上的notify()
或notifyAll()
方法时,等待的线程(可能)会被移出 Wait Set 并尝试重新获取锁。
5.4 锁的优化
随着 JVM 的发展,synchronized
的性能得到了显著提升。一些重要的优化技术包括锁偏向、轻量级锁(自旋锁)和重量级锁。
- 偏向锁:大多数情况下,锁不仅是不必要的,而且是被同一线程多次获得。偏向锁优化是通过消除锁释放和再次获取的成本,来提高性能的。
- 轻量级锁:当偏向锁不再适用时(即,有多个线程竞争同一个锁),轻量级锁通过自旋来等待锁释放,以减少线程挂起的开销。
- 重量级锁:当有大量线程竞争同一个锁,且自旋等待未能获得锁时,轻量级锁会升级为重量级锁,导致其他线程阻塞,直到锁被释放。
通过这些锁的升级和优化,synchronized
能够在不牺牲太多性能的前提下,提供线程安全保障。
synchronized
是 Java 中实现同步的一种基本方式,广泛用于对方法或代码块进行加锁以保障线程安全。随着 JDK 的发展,为了提高 synchronized
的性能,Java 虚拟机(JVM)在内部对其进行了多种优化。这些优化主要围绕锁的升级与降级,主要包括轻量级锁、偏向锁以及锁粗化等技术。
5.5 锁的升级过程
-
偏向锁 (Biased Locking) 当一个锁被一个线程访问后,JVM 假定它很可能再次被同一个线程访问。因此,JVM 会将锁对象标记为偏向于该线程,避免在未来的锁获取过程中进行不必要的同步。偏向锁可以通过 JVM 启动参数
-XX:+UseBiasedLocking
开启(在 JDK 6u23 之后,默认启用)。如果另一个线程尝试获取这个锁,偏向模式结束,锁会升级为轻量级锁。
-
轻量级锁 (Lightweight Locking) 当偏向锁失败时(即,多个线程尝试获取同一个锁),锁会升级为轻量级锁。轻量级锁通过在对象头上存储锁记录(Lock Record)或者锁指针来实现。如果一个线程已经持有了轻量级锁,其他线程会通过自旋等待锁释放。轻量级锁适用于执行时间短且线程争用少的场景。
轻量级锁如果导致长时间自旋,为了避免 CPU 资源浪费,将会升级为重量级锁。
-
重量级锁 (Heavyweight Locking) 如果轻量级锁的自旋已无法获得锁,那么锁会再次升级为重量级锁。这时,JVM 会从操作系统层面请求锁,此种锁会导致其他尝试获取锁的线程被阻塞(挂起状态),直到锁被释放。重量级锁有明显的性能代价,因为涉及到操作系统的线程调度和上下文切换。
6、并发基础
6.1 AQS
AQS(AbstractQueuedSynchronizer)是Java并发包中的一个核心组件,它为实现依赖先进先出(FIFO)等待队列的阻塞锁和相关同步器(信号量、事件等)提供了一个可靠的基础框架。AQS在java.util.concurrent.locks包中,是实现锁和同步器的关键抽象类。
6.2 AQS的核心思想
AQS使用一个整数(state)来表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,它还使用了模板方法模式,自身提供了一系列可重写的保护方法来定制状态的获取和释放。
6.3 AQS的主要特性
-
状态(State): AQS内部维护一个状态信息,用于控制同步器的状态。具体意义取决于实现,例如,对于ReentrantLock,状态指的是持有锁的数量;对于Semaphore,状态指的是当前可用的许可证数量。
-
节点(Node)和等待队列: 等待获取资源的线程会被封装成节点(Node)实例,并加入到AQS维护的一个FIFO队列中。如果一个线程获取同步状态失败,它会成为该队列中的一个节点。
-
获取(acquire)和释放(release)方法: AQS提供了一系列acquire方法来获取状态,以及一系列release方法来释放状态。这些方法可以自定义状态的获取和释放逻辑,以适应不同的同步组件。
6.4 AQS的方法分为两类
-
独占模式: 这种模式下每次只能有一个线程持有资源,例如
ReentrantLock
就是基于AQS独占模式实现的。 -
共享模式: 这种模式下多个线程可以同时持有资源,例如
Semaphore
、CountDownLatch
等基于AQS的共享模式实现的。
6.5 AQS提供的方法概览
int getState()
: 获取当前同步状态。void setState(int newState)
: 设置当前同步状态。boolean compareAndSetState(int expect, int update)
: 原子地更新同步状态,期望值与实际值一致时,更新为新的值。
6.6 AQS的子类需要重写的方法
protected boolean tryAcquire(int arg)
: 独占方式。尝试获取资源,成功则返回true,失败则返回false。protected boolean tryRelease(int arg)
: 独占方式。尝试释放资源,成功则返回true,失败则返回false。protected int tryAcquireShared(int arg)
: 共享方式。尝试获取资源。负数表示失败;0表示成功,但没有可用资源;正数表示成功,且有剩余资源。protected boolean tryReleaseShared(int arg)
: 共享方式。尝试释放资源,成功则返回true,失败则返回false。
AQS的这些方法提供了同步器的基本行为,具体的同步组件(如ReentrantLock
, Semaphore
, CountDownLatch
, ReadWriteLock
等)会根据需求实现这些方法,以提供其独特的同步机制。
总之,AQS是实现同步锁和其他同步器的一个强大的框架,它利用了状态、队列和模板方法模式的概念来简化同步器的实现,并提供了高效、可靠的多线程同步功能。
6.7 线程的阻塞与唤醒
在JDK的并发包中,线程的阻塞和唤醒是由底层操作系统的线程调度器和Java虚拟机(JVM)协同完成的。Java提供了多种机制来控制线程的阻塞与唤醒,这些机制利用锁、条件变量、等待/通知模式等来管理线程状态。下面讨论一些主要概念和方法:
6.7.1 synchronized 和 Object 的 wait/notify 机制
synchronized
关键字可以用来提供一个代码块或者方法的同步锁。 当一个线程进入synchronized
代码块时,它获取了一个锁(monitor),其他线程必须等待这个锁被释放才能进入代码块。
-
wait(): 一个线程在持有monitor锁的条件下,可以调用对象的
wait()
方法导致当当前线程挂起并且释放锁。该线程会放到对象的等待队列中。 -
notify()/notifyAll(): 同样在持有monitor锁的条件下,可以调用对象的
notify()
方法或notifyAll()
方法来唤醒在该对象上调用wait()
方法而挂起的线程。notify()
唤醒单个线程,而notifyAll()
唤醒所有线程。
6.7.2 Lock 和 Condition 接口
Lock
接口提供了比synchronized
关键字更加丰富的锁操作功能。这些功能包括尝试非阻塞地获取锁、能被中断的锁获取操作,以及超时的锁获取操作。
与Lock
相关联的是Condition
接口,它类似于Object类中的wait()
、notify()
和notifyAll()
方法:
-
await(): 类似于
Object
类中的wait()
方法,可以使线程挂起并释放锁。 -
signal()/signalAll(): 这些方法类似于
Object
类中的notify()
和notifyAll()
,可以唤醒一个或多个在Condition
上等待的线程。
6.7.3 AQS (AbstractQueuedSynchronizer)
AQS在其内部使用了一个FIFO队列来管理那些请求了互斥锁但是没有成功获取锁(即处于阻塞状态)的线程。当阻塞的线程被唤醒(比如持有锁的线程释放了锁),AQS会选择一个或多个线程来尝试获取锁,并从阻塞状态恢复为就绪状态。
AQS使用了几种状态来控制线程的阻塞与唤醒:
- acquire(int arg): 如果没有成功获取到锁,会调用内部的方法将线程包装成节点并加入等待队列。
- release(int arg): 当持有锁的线程执行完成并释放锁时,会调用内部的方法唤醒等待队列中的后续节点(即线程)。
6.7.4 操作系统的支持
Java线程的阻塞和唤醒最终是通过操作系统的支持实现的,无论是使用synchronized
、Lock
、Condition
还是AQS,底层都涉及到操作系统对线程状态的控制。Java虚拟机通过本地方法接口(JNI)调用操作系统提供的线程相关函数来完成线程的挂起、唤醒和调度工作。
以上方法,不同的同步机制提供了多种多样的管理线程阻塞和唤醒的方式,而在底层,这些机制都依赖于操作系统的线程调度器以及JVM的实现细节来保证其准确性和效率。
6.8 CAS
CAS(比较并交换,Compare-And-Swap)是一种用于实现多线程同步的机制,主要通过硬件提供的原子指令来实现。CAS操作包含三个操作数——内存位置(V,表示要更新的变量),预期原值(A),和新值(B)。CAS指令执行时,只有当内存位置的值与预期原值A相等,系统才会自动将该位置值更新为新值B;如果内存位置的值与A不相等,表示有其他线程已经修改了该值,则CAS操作失败。
CAS操作是无锁编程的基础,因为它允许在不使用锁的情况下进行同步。许多现代编程语言都提供了对CAS操作的支持。在Java中,java.util.concurrent.atomic
包下的原子类使用了CAS操作来实现原子性操作。
CAS操作有以下步骤:
- 读取当前的值。
- 计算新值。
- 使用CAS指令尝试原子更新,比较当前值与原始读取的值。
- 如果期间未被其他线程更新,则更新成功;如果已被其他线程更新,则失败,可能需要重试。
CAS的好处是性能高,因为它不涉及锁定,所以没有锁的开销,也就避免了上下文切换和死锁的风险。但CAS也有其缺点:
- ABA问题:由于CAS需要在操作时检测当前值是否发生过变化(只检查了“现在”和“之前”),假如一个变量原来是A,变成了B,然后又变回A,使用CAS进行比较时会认为它没有变化。虽然看起来没有问题,但在并发情况下可能会带来问题。解决办法是使用带版本号的CAS,如AtomicStampedReference,它可以通过版本号来解决这个问题。
7、锁
7.1 ReentrantLock
ReentrantLock
是 Java 中的同步机制,用于在多线程场景中实现锁定,保证临界区的代码能够互斥访问,从而避免并发问题。ReentrantLock
实现了 Lock
接口,并提供了与 synchronized
关键字类似的同步功能,但比 synchronized
提供更多的灵活性和扩展性。
7.2 ReentrantLock 的底层原理
-
状态变量: ReentrantLock 内部维护了一个表示锁状态的变量,用来记录锁的占用情况。这个状态变量在无锁状态时为 0,在锁被某个线程占用时至少为 1,如果锁是可重入的,状态变量表示锁被持有的次数。
-
CAS操作:
ReentrantLock
使用compareAndSet
操作(CAS)来尝试获取锁。如果状态变量为 0,CAS 将其设置为 1,表明线程获取了锁。如果状态变量不为 0,则表示锁已经被其他线程占用。 -
AQS框架:
ReentrantLock
底层采用了 AbstractQueuedSynchronizer(AQS)这一同步框架。AQS 使用一个双向队列(CLH 队列)来维护等待锁的线程。如果线程尝试获取锁失败,它会被加入到等待队列的尾部,并处于阻塞状态。 -
锁的释放: 当锁的持有者释放锁时,状态变量会被更新。如果有线程在等待队列中,AQS 框架将唤醒队列头部的线程来尝试获取锁。
-
公平锁与非公平锁:
ReentrantLock
提供公平锁和非公平锁。公平锁的意味着在释放锁时,锁会被授予等待时间最长的线程;非公平锁则允许插队(刚好有新的线程请求获取锁,由于这个线程是未阻塞的,比起唤醒阻塞的线程代价小,这样可以减少线程切换的开销,提供吞吐量),可能导致某些线程等待时间过长,甚至饿死。
7.3 如何使用 ReentrantLock
使用 ReentrantLock
时,需要在代码中显式地获取锁和释放锁。下面是一个简单的使用示例:
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // 获取锁
try {
count++;
} finally {
lock.unlock(); // 释放锁
}
}
public int getCount() {
return count;
}
public static void main(String[] args) {
Counter counter = new Counter();
// ...线程A
new Thread(() -> {
counter.increment();
}).start();
// ...线程B
new Thread(() -> {
counter.increment();
}).start();
// 主线程继续其他工作...
}
}
在上面的例子中,increment
方法中使用了 ReentrantLock
来确保计数器 count
的自增操作是线程安全的。
总的来说,ReentrantLock
提供了一个更加灵活、更为细粒度的线程同步机制,但要注意始终在一个 try
块中获取锁,并在相应的 finally
块中释放锁,这样可以确保即使在发生异常的情况下,锁也会被正确释放。
7.4 ReentrantReadWriteLock
ReentrantReadWriteLock
是Java中的一个高级同步锁,提供了一种具有读写锁特性的同步机制。它允许多个线程同时读取一个资源,但只允许一个线程写入资源。这种机制可以提高性能,特别是在读操作远多于写操作的情况下。
7.4.1 特点
- 读写分离: 锁分为读锁和写锁,多个线程可以同时持有读锁,但写锁是独占的。
- 公平性选项: 可以配置为公平锁或非公平锁。公平锁按照请求锁的顺序来获取锁,非公平锁则不保证这点。默认情况下是非公平模式。
- 可重入性: 线程可以重复获取已经持有的锁。这意味着当一个线程获得写锁后,它可以再次申请得到这个写锁,同理读锁也是。
- 锁降级: 支持锁降级从写锁到读锁,反之则不行。
- 中断支持: 支持在锁的等待过程中响应中断。
与只在写上加ReentrantLock锁,读不加锁相比,能提供严格的一致性。(写时,读阻塞)
7.4.2 Condition
在Java中,Condition
接口配合锁(Lock)使用,提供了一种比传统Object
监视器方法(wait
,notify
和notifyAll
)更强大和灵活的线程间协调机制。Condition
接口提供了一种分离锁对象(Lock
)和等待/通知的方法,它允许多个等待集合与单个锁相关联。
使用Condition
时,需要与一个实现了Lock
接口的锁对象相关联。通常这是ReentrantLock
类的一个实例。每个Condition
实例提供了类似于Object
监视器方法的行为,但它们对各自的Condition
实例来说是相互独立的。
一个典型的Condition
对象使用模式是这样的:
- 获取一个
Lock
对象,并与之关联一个Condition
对象。 - 锁定
Lock
锁,以获取对相关资源的独占访问。 - 调用
Condition.await()
方法使当前线程等待,直到它被另一个线程的Condition.signal()
或Condition.signalAll()
方法唤醒,或被中断。 - 当线程被唤醒后,尝试重新获取锁并继续执行。
- 在离开临界区之前,释放锁。
下面是一个使用ReentrantLock
和Condition
的简单例子:
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ProducerConsumerExample {
private final int MAX_CAPACITY = 5;
private final Queue<Integer> queue = new LinkedList<>();
private final Lock lock = new ReentrantLock();
private final Condition bufferNotFull = lock.newCondition();
private final Condition bufferNotEmpty = lock.newCondition();
// 生产者方法
public void produce(int value) {
lock.lock(); // 获取锁
try {
// 当队列满时,等待
while (queue.size() == MAX_CAPACITY) {
bufferNotFull.await();
}
// 在队列不满时生产一个元素
queue.add(value);
System.out.println("Produced: " + value);
// 通知消费者队列不为空了
bufferNotEmpty.signalAll(); // 唤醒所有等待消费的线程
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 确保释放锁
}
}
// 消费者方法
public void consume() {
lock.lock(); // 获取锁
try {
// 当队列为空时,等待
while (queue.isEmpty()) {
bufferNotEmpty.await();
}
// 在队列不为空时消费一个元素
int value = queue.poll();
System.out.println("Consumed: " + value);
// 通知生产者队列现在不是满的了
bufferNotFull.signalAll(); // 唤醒所有等待生产的线程
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 确保释放锁
}
}
}
在这个示例中:
- 我们有一个
MAX_CAPACITY
常量来表示队列可以拥有的最大元素数量。 produce()
方法用于将元素放入队列中,并在队列满时等待。consume()
方法用于从队列相取元素,并在队列空时等待。produce()
方法在新元素被加入后用signalAll()
来唤醒所有可能因为队列空而等待的消费者。consume()
方法在元素被消费后用signalAll()
来唤醒所有可能因为队列满而等待的生产者。bufferNotFull
和bufferNotEmpty
是与lock
锁相关联的两个Condition
,分别用于生产者等待/通知和消费者等待/通知。
请注意,由于多个生产者或多个消费者可能会同时被唤醒,所以仍然需要在生产消费逻辑中使用循环(while
而不是if
)来做判断,以防止虚假唤醒导致的问题。
8、并发工具类
8.1 CountDownLatch
CountDownLatch
是 Java 并发编程中的一个同步辅助类,可用于确保一个任务在其他一组任务全部完成之前,一直等待阻塞。与CyclicBarrier
不同,CountDownLatch
只能使用一次,其计数器无法重置。
// 假设我们需要等待5个任务
final int taskCount = 5;
final CountDownLatch latch = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
new Thread(() -> {
try {
// 执行任务
System.out.println(Thread.currentThread().getName() + " 执行任务");
// 模拟延时
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 每完成一个任务,计数器减1
latch.countDown();
}
}).start();
}
try {
// 主线程在此等待,直到所有任务都执行完毕(计数器为0)
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("所有任务执行完毕,主线程继续执行...");
8.2 CyclicBariier
CyclicBarrier
通常用于在并发程序中实现一种同步机制,即多个线程必须等待彼此同时到达一个共同点,才能各自继续执行。它可以重复使用。
final int threadCount = 3;
CyclicBarrier barrier = new CyclicBarrier(threadCount);
for(int i = 0; i < threadCount; i++) {
Thread thread = new Thread(() -> {
System.out.println("线程 " + Thread.currentThread().getName() + " 正在执行任务。");
try {
barrier.await(); // 等待其他线程
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println("线程 " + Thread.currentThread().getName() + " 继续执行后续操作。");
});
thread.start();
}
外面可以再套一层循环,打印多轮次。
8.3 Semphore
Semaphore
(信号量)是Java并发库(java.util.concurrent
)中提供的一种同步机制,它可以控制对共享资源的访问数量。通过使用信号量,你可以实现资源池,限制访问资源的线程数量,从而实现如限流控制等功能。在信号量上,线程可以申请许可(permits),当许可可用时,线程可以访问受限资源;当完成资源访问后,线程释放许可,以供其他线程使用。
8.3.1 Semaphore的基本用法
创建Semaphore
实例时,你需要指定许可的总数量。Semaphore
提供了两个主要方法:acquire()
用于获取一个许可,如果没有可用许可它会阻塞直到有许可成为可用;release()
用于释放许可,增加可用许可的数量。
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) {
// 创建一个Semaphore实例,它只有3个许可
Semaphore semaphore = new Semaphore(3);
// 模拟10个线程,每个线程都尝试获取许可
for (int i = 0; i < 10; i++) {
final int index = i;
new Thread(() -> {
try {
semaphore.acquire(); // 获取一个许可
System.out.println("线程" + index + "获得许可");
// 模拟工作: 线程持有许可一段时间
Thread.sleep((long) (Math.random() * 10000));
semaphore.release(); // 释放许可
System.out.println("线程" + index + "释放许可");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}
8.3.2 信号量的公平性
在创建信号量时,可以选择是否启用公平模式。默认情况下,Semaphore
是非公平的,这意味着线程获取许可的顺序不一定按照申请许可的顺序进行。如果选择启用公平模式,那么在多个线程等待许可时,等待时间最长的线程将会优先获得许可。
Semaphore fairSemaphore = new Semaphore(permits, true); // 启用公平模式
9、并发集合
9.1 ConcurrentHashMap
在Java 1.7中,ConcurrentHashMap
内部使用了分段锁的机制(Segmentation),其核心组件是一个叫做Segment的数组。每个Segment本质上是一个小的哈希表,附带着一个锁。每个Segment独立地锁定,因此多线程可以同时操作不同的Segment,这样即使是在写操作时也允许高度的并发。当需要执行put或者remove操作时,线程首先根据哈希值选择正确的Segment,再对这个Segment加锁。
这里的关键思想是细化锁的粒度:不是每一次写操作都锁定整个数据结构,而是锁定数据被分成了多个部分,这样就多个写操作往往可以同时进行。
Java 1.8中ConcurrentHashMap
还引入了一种不同的更新策略 —— CAS(Compare-And-Swap)操作与synchronized同步块。对于普通的Node进行更新时,首先尝试使用基于CAS的无锁机制;如果CAS操作失败,意味着有其他线程在进行更新操作,这时会使用synchronized来进行加锁处理。
9.2 ConcurrentLinkedQueue
10、原子操作
10.1 基本类型
10.1.1 AtomicBoolean
AtomicBoolean
与 AtomicInteger
类似,其原子性是通过 CAS(Compare-And-Swap)操作实现的。CAS 是最底层的一种原子操作,直接由 CPU 提供支持。CAS 操作需要指定三个参数:一个内存地址 V,一个期望值 E 和一个新值 A。如果地址 V 上的值与期望值 E 相等,那么就将这个地址上的值设置为新值 A。整个操作是原子性的,这意味着中间不会被其他线程打断。
在 Java 中,AtomicBoolean
内部使用一个 volatile int
来表示布尔值,这个 int
类型的值为 0 时表示 false
,非 0(通常为 1)时表示 true
。volatile
修饰符保证了该变量的可见性,即一个线程内对这个变量的修改会立即被其他线程所观察到。
10.1.2 AtomicInteger
AtomicInteger
的原子性主要是依靠 CAS(Compare-And-Swap)算法实现的。CAS 操作包含三个操作数 —— 内存位置(V, 表示变量的地址)、预期原值(E)和新值(A)。CAS 操作的执行逻辑是:
- 如果内存位置的当前值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。
- 否则,处理器不做任何操作。
- 不论更新与否,它都会返回该位置的旧值。
CAS 是一种无锁编程的重要概念,其优点是减少了锁的使用,降低了线程阻塞的可能性,从而提高系统性能。然而,它的缺点包括可能的活锁(两个或多个线程不断尝试更新变量却始终失败,循环重试)以及只能保证一个变量的原子性操作。
10.1.3 AtomicLong
AtomicLong
具体如何使用 CAS 实现原子操作呢?其内部有一个 volatile long
类型的值,它使用 Unsafe
类来直接执行原子操作。由于 volatile
修饰符的存在,确保了这个变量在多个线程之间的可见性,而 CAS 操作确保了原子性。
10.2 数组
10.2.1 AtomicIntegerArray
AtomicIntegerArray
是 Java 并发包 java.util.concurrent.atomic
中提供的一个类,它保证了对整型数组的元素进行线程安全的原子操作。AtomicIntegerArray
是内部持有一个 int 类型数组的一个封装器,它确保在并发访问的环境下,对数组元素的修改是原子的,并且能够立即对所有线程可见(因为数组的每个元素都被声明为 volatile
)。
10.2.2 AtomicLongArray
与上面类似
10.2.3 AtomicReferenceArray
参考AtomicReference,他只是个数组。
10.3 引用类型
10.3.1 AtomicReference
存储一个对象,他能保证对这个对象的引用操作是原子的,不能保证操作这个对象的内部字段是原子的。
10.3.2 AtomicReferenceFieldUpdater
AtomicReferenceFieldUpdater
是 Java 并发包 java.util.concurrent.atomic
中的一个工具类,它提供了一种线程安全的方式来更新对象的引用字段,即便这个字段不是声明为 volatile
类型的。通过这个类,你可以在不暴露对象的私有字段情况下保持这些字段的线程安全,同时也避免了 AtomicReference
对象本身的包装额外开销。
10.3.3 如何使用 AtomicReferenceFieldUpdater
首先,你需要创建 AtomicReferenceFieldUpdater
的一个实例。下面是一个使用 AtomicReferenceFieldUpdater
的基本示例:
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
class Person {
volatile String name; // 必须是 volatile 字段
Person(String name) {
this.name = name;
}
}
public class AtomicReferenceFieldUpdaterDemo {
private static final AtomicReferenceFieldUpdater<Person, String> nameUpdater =
AtomicReferenceFieldUpdater.newUpdater(Person.class, String.class, "name");
public static void main(String[] args) {
Person person = new Person("John");
// 使用 AtomicReferenceFieldUpdater 来更新字段值
boolean updated = nameUpdater.compareAndSet(person, "John", "Jane");
System.out.println("Updated: " + updated + ", New name: " + person.name);
}
}
11、线程池
11.1 Callable和Future
Callable是实现多线程的一中, 其他两种是实现Runnable接口和继承Thread类。Callable会返回一个Future,且可以抛出异常。
Future get方法阻塞的原理,可以见FutureTask的awaitDone方法,主要是用到了自旋(for(;;))和CAS,通过判断state是否已经执行完。
11.2 CompletableFuture
12 、其他
12.1 ThreadLocal
每个线程类都有个内部类ThreadLocalMap,他是以ThreadLocal为key,用户变量为value。因为多线程下,虽然key是一样的,大家的map是隔离的,也就是线程是安全的。
12.2 Fork/Join
12.2.1 使用场景
Fork/Join
框架适用于可分解(分而治之)的大型计算密集型任务,比如:
- 数组或集合的并行处理
- 并行快速排序或归并排序
- 多维数组的大规模操作
- 并行搜索任务
12.2.2 使用频率
尽管Fork/Join
在引入时被誉为并行编程的重要工具,但在实际开发中,它并不是总是首选。对于某些任务来说,它可能比传统的线程池(如ExecutorService
)更复杂,而后者很容易被理解和使用。此外,性能并不总是预期那样好,特别是在任务不能很好地分解或者合并结果的消耗很高时。
随着 Java 8 引入的Streams API
,其提供了比Fork/Join
更高层次的抽象,很多情况下开发者更偏向于使用 Streams 进行并行数据处理,因为它提供了更简单直观的并行处理模型,并且可以自动利用Fork/Join
框架。
总之,Fork/Join
的使用并不像其他并发机制(如同步、锁、普通的线程池)那样普遍,但它仍然是解决特定问题(尤其是需要分解和递归来处理的并行任务)的有力工具。正确选择并发机制需要根据具体的任务需求、易用性和性能特点来决定。
13、Java线程间通信
1、使用共享变量(静态变量)
2、使用Object类的wait()/notify()/notifyAll
3、使用ReentrantLock加锁类的线程的Condition类的await()/signal()/signalAll()
4、使用管道进行线程间通信:1)字节流;2)字符流
5、使用JDK并发工具、并发集合
14、Java线程同步方法
1、同步方法(synchronized方法)
2、同步代码块(synchronized代码块)
3、使用特殊域变量(volatile)实现线程同步(比较适合一写多读)
4、使用重入锁实现线程同步(ReentrantLock lock()/unlock()方法)
5、使用局部变量实现线程同步 (个人认为相当于绕过了问题)
6、使用阻塞队列等并发容器
7、使用原子变量实现线程同步
个人认为线程同步方法只有一种,那就是加锁。线程同步就是将并发变成串行,上述方法除了volatile和threadlocal,其他的都是锁,或者底层都使用了锁。使用JDK提供的丰富的并发工具能大幅度简化代码,但实质都是使用了锁,像阻塞队列源码,大量使用了synchronized关键字。
JDK 1.6发布之后,人们就发现Synchronized与ReentrantLock的性能基本上是完全持平了。因此,如果读者的程序是使用JDK 1.6或以上部署的话,性能因素就不再是选择ReentrantLock的理由了,虚拟机在未来的性能改进中肯定也会更加偏向于原生的Synchronized,所以还是提倡在Synchronized能实现需求的情况下,优先考虑使用Synchronized来进行同步----《深入理解Java虚拟机》。
所以如果你的编程能力比较强,一把锁走天下也不是不可以。(synchronized+wait/notify/notifyall,因为并发工具JDK1.5之后才提供的,早期的代码也不得不这么写)。当然我也还是推荐使用高级的API。
线程间通信侧重线程之间交流、通信,线程同步是为了保护不能并发的资源,往往是将并发变成串行。
参考