Java并发编程-并发安全篇

Java提供了多种机制来保证线程安全,包括内置锁、显式锁、原子类、线程局部变量和并发集合等。本文将深入探讨Java中的并发安全机制,涵盖线程同步、锁机制、原子操作、线程局部变量以及并发集合等内容。

如何保证多线程安全

在 Java 中,保证多线程安全主要有以下几种方式:

1.synchronized 关键字

使用 synchronized 关键字来同步代码块或方法,确保同一时刻只有一个线程能访问这些代码。

优点:简单易用,适用于简单的同步需求。

缺点:可能会导致性能问题,特别是在高并发场景下。可能会导致死锁。

2. volatile 关键字

使用 volatile 关键字,确保所有的线程都能看到该变量的最新值,而不是可能存储在本地寄存器中的副本。

优点:确保变量的可见性,适用于简单的状态标志。

缺点:不能保证复合操作的原子性,如 count++

3. Lock 接口和 ReentrantLock

使用 Lock 接口和 ReentrantLock 类来实现更灵活的锁机制。

优点:提供更灵活的锁机制,支持可中断锁、公平锁等。

缺点:需要手动管理锁的获取和释放,容易出错。

4. 原子类

Java 并发库提供了原子类,这些类提供原子操作,可以用来更新基本数据类型而无需同步。

优点:提供原子操作,无需手动同步。

缺点:仅适用于基本数据类型的原子操作。

5. 线程局部变量 ThreadLocal

可以为每个线程创建独立的副本,这样每个副本都拥有自己的变量,消除竞争条件。

优点:每个线程拥有独立的变量副本,消除竞争条件。

缺点:可能会导致内存泄漏,特别是在线程池中使用时。

6. 并发集合

java.util.concurrent 包中提供了线程安全的集合,已实现线程安全逻辑。

优点:提供线程安全的集合操作,无需手动同步。

缺点:可能会导致性能问题,特别是在高并发场景下。

Java 中常用的锁及其使用场景

在 Java 中,锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java 提供了多种锁机制,以下是常用的锁及其使用场景:

1. 内置锁 (synchronized)

synchronized 关键字是 Java 内置的锁机制,可以用于方法或代码块。当一个线程进入 synchronized 代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。

优点:简单易用,适用于简单的同步需求。

缺点:可能会导致性能问题,特别是在高并发场景下。可能会导致死锁。

使用场景:适用于简单的同步需求,如单个对象的同步访问。

示例代码

1
2
3
4
5
6
7
8
9
10
11
public class SynchronizedExample {
private int count = 0;

public synchronized void increment() {
count++;
}

public synchronized int getCount() {
return count;
}
}

2. ReentrantLock

java.util.concurrent.locks.ReentrantLock 是一个显式的锁类,提供了比 synchronized 更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。

优点:提供更灵活的锁机制,支持可中断锁、公平锁等。

缺点:需要手动管理锁的获取和释放,容易出错。

使用场景:适用于需要更高级锁功能的场景,如可中断锁、公平锁等。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();

public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}

public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}

3. 读写锁 (ReadWriteLock)

java.util.concurrent.locks.ReadWriteLock 接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。

优点:适用于读取远多于写入的场景,提高并发性。

缺点:实现复杂,需要管理读锁和写锁的获取和释放。

使用场景:适用于读取操作远多于写入操作的场景,如缓存系统。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
private int count = 0;
private final ReadWriteLock lock = new ReentrantReadWriteLock();

public void increment() {
lock.writeLock().lock();
try {
count++;
} finally {
lock.writeLock().unlock();
}
}

public int getCount() {
lock.readLock().lock();
try {
return count;
} finally {
lock.readLock().unlock();
}
}
}

4. 乐观锁和悲观锁

  • **悲观锁 (Pessimistic Locking)**:在访问数据前就锁定资源,假设最坏的情况即数据很可能被其他线程修改。synchronizedReentrantLock 都是悲观锁的例子。
  • **乐观锁 (Optimistic Locking)**:通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。

优点:乐观锁适用于冲突较少的场景,减少锁的开销;悲观锁适用于冲突较多的场景,确保数据一致性。

缺点:乐观锁在冲突较多时性能较差;悲观锁在冲突较少时性能较差。

使用场景

  • 乐观锁适用于读多写少的场景,如版本控制系统。
  • 悲观锁适用于写操作频繁的场景,如数据库事务。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticLockExample {
private AtomicInteger version = new AtomicInteger(0);
private int data = 0;

public boolean updateData(int expectedVersion, int newData) {
if (version.compareAndSet(expectedVersion, expectedVersion + 1)) {
data = newData;
return true;
}
return false;
}

public int getData() {
return data;
}

public int getVersion() {
return version.get();
}
}

5. 自旋锁

自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃 CPU 并阻塞。通常可以使用 CAS(Compare-And-Swap)来实现。

优点:在锁等待时间很短的情况下可以提高性能。

缺点:过度自旋会浪费 CPU 资源。

使用场景:适用于锁等待时间很短的场景,如轻量级同步。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.concurrent.atomic.AtomicBoolean;

public class SpinLockExample {
private AtomicBoolean lock = new AtomicBoolean(false);

public void acquire() {
while (!lock.compareAndSet(false, true)) {
// 自旋等待
}
}

public void release() {
lock.set(false);
}
}

什么是可重入锁

可重入锁(Reentrant Lock)是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁时,如果再次尝试获取该锁,就会成功获取而不会被阻塞。

可重入锁的工作原理

ReentrantLock 实现可重入锁的机制是基于线程持有锁的计数器。具体工作原理如下:

  1. 计数器初始化:当一个线程第一次获取锁时,计数器会加 1,表示该线程持有了锁。
  2. 重复获取锁:在此之后,如果同一个线程再次获取锁,计数器会再次加 1。每次线程成功获取锁时,都会将计数器加 1。
  3. 释放锁:当线程释放锁时,计数器会相应地减 1。只有当计数器减到 0 时,锁才会完全释放,其他线程才有机会获取锁。
  4. 避免死锁:这种计数器的设计使得同一个线程可以多次获取同一个锁,而不会造成死锁或其他问题。每次获取锁时计数器加 1;每次释放锁时,计数器减 1。只有当计数器减到 0 时,锁才会完全释放。

synchronizedReentrantLock 的区别

synchronized 工作原理

synchronized 是 Java 提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁。使用 synchronized 之后,会在编译之后在同步的代码块前后加上 monitorentermonitorexit 字节码指令,它依赖操作系统底层互斥锁实现。它的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。

工作原理

  • 执行 monitorenter 指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器 +1。此时其他竞争锁的线程则会进入等待队列中。
  • 执行 monitorexit 指令时则会把计数器 -1,当计数器值为 0 时锁释放,处于等待队列中的线程再继续竞争锁。

特点

  • synchronized 是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁。
  • 由于 Java 中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时会从用户态切换到内核态,这种转换非常消耗性能。
  • 从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。

ReentrantLock 工作原理

ReentrantLock 的底层实现主要依赖于 AbstractQueuedSynchronizer (AQS) 这个抽象类。AQS 是一个提供了基本同步机制的框架,其中包括了队列、状态值等。

工作原理

  • ReentrantLock 在 AQS 的基础上通过内部类 Sync 来实现具体的锁操作。不同的 Sync 子类实现了公平锁和非公平锁的不同逻辑。
  • ReentrantLock 提供了更灵活的锁机制,支持可中断的锁等待、定时锁等待、公平锁选项等。

特点

  • 可见性ReentrantLock 通过 volatile 变量来保证锁状态的可见性。
  • 设置超时时间ReentrantLock 支持在获取锁时设置超时时间,避免无限等待。
  • 公平锁和非公平锁ReentrantLock 提供了公平锁和非公平锁的选项,公平锁按照线程请求锁的顺序来分配锁,非公平锁不保证锁分配的顺序。
  • 多个条件变量ReentrantLock 支持多个条件变量,可以更细粒度地控制线程的等待和唤醒。
  • 可重入性ReentrantLock 支持可重入性,即同一个线程可以多次获取同一个锁。

区别

synchronizedReentrantLock 都是 Java 中提供的可重入锁,但它们在用法、获取和释放锁的方式、锁类型、响应中断以及底层实现等方面存在显著差异。

1.用法不同

  • **synchronized**:可以用来修饰普通方法、静态方法和代码块。
  • **ReentrantLock**:只能用在代码块中。

2.获取锁和释放锁方式不同

  • **synchronized**:会自动加锁和释放锁。当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码段之后会自动释放锁。
  • **ReentrantLock**:需要手动加锁和释放锁。通过 lock() 方法获取锁,通过 unlock() 方法释放锁。

3.锁类型不同

  • **synchronized**:属于非公平锁。
  • **ReentrantLock**:既可以是公平锁也可以是非公平锁。通过构造函数可以指定锁的类型。

4.响应中断不同

  • **ReentrantLock**:可以响应中断,解决死锁的问题。通过 lockInterruptibly() 方法可以实现可中断的锁等待。
  • **synchronized**:不能响应中断。

5.底层实现不同

  • **synchronized**:是 JVM 层面通过监视器(Monitor)实现的。在编译后的字节码中会生成 monitorentermonitorexit 指令。
  • **ReentrantLock**:是基于 AQS(AbstractQueuedSynchronizer)实现的。AQS 是一个提供了基本同步机制的框架,其中包括了队列、状态值等。

synchronized 锁升级过程

synchronized 锁在 Java 中的升级过程是一个逐步优化的过程,从无锁状态到偏向锁、轻量级锁,最终升级为重量级锁。这个过程旨在根据不同的竞争情况,动态调整锁的实现方式,以提高性能。

锁升级过程

1. 无锁状态

  • 描述:这是还没有开启偏向锁时的状态。JVM 启动后会有一个偏向延时,延迟一段时间后才会开启偏向锁。
  • 特点:无锁状态下,对象的 Mark Word 中存储的是对象的哈希码和分代年龄等信息。

2. 偏向锁

  • 描述:偏向锁开启后的锁状态。如果无线程拿到该锁,这个状态叫匿名偏向。当一个线程想要竞争该锁时,只需要拿线程 ID 和 Mark Word 中存储的线程 ID 比较,如果线程 ID 相同则直接获取锁(即锁偏向于这个线程),不需要进行 CAS 操作和将线程挂起。
  • 特点:偏向锁减少了无竞争情况下的锁开销,适用于单线程访问的场景。

3. 轻量级锁

  • 描述:在这个状态下,线程主要通过 CAS(Compare-And-Swap)操作实现。将对象的 Mark Word 存储到线程的虚拟栈上,然后将对象的 Mark Word 更新为指向线程栈中锁记录的指针。
  • 特点:轻量级锁适用于竞争不激烈的场景,通过 CAS 操作避免了线程挂起和唤醒的开销。

4. 重量级锁

  • 描述:当两个以上的线程获取锁时,轻量级锁就会升级为重量级锁。因为 CAS 操作如果没有成功的话,线程会自旋等待,进行 while 循环操作,非常消耗 CPU 资源。
  • 特点:重量级锁通过操作系统底层的互斥锁实现,适用于高并发竞争场景。

锁升级的触发条件

  • 无锁到偏向锁:JVM 启动后经过偏向延时,默认情况下偏向锁是开启的。
  • 偏向锁到轻量级锁:当有其他线程尝试获取偏向锁时,偏向锁会升级为轻量级锁。
  • 轻量级锁到重量级锁:当多个线程竞争同一个锁时,轻量级锁会升级为重量级锁。

synchronized 锁的升级过程是一个动态优化的过程,从无锁状态到偏向锁、轻量级锁,最终升级为重量级锁。这个过程根据不同的竞争情况,动态调整锁的实现方式,以提高性能。

AQS

AbstractQueuedSynchronizer(简称 AQS)是 Java 中的一个抽象类,是用于构建锁、同步器、协作工具类的工具类(框架)。AQS 提供了一个基于 FIFO 队列的阻塞锁和相关的同步器(如信号量、事件等)的框架,是 Java 并发包(java.util.concurrent)的基础。

AQS 的核心思想

AQS 的核心思想是,如果当前请求的资源空闲,那么就将当前请求资源的线程设置为有效工作线程,将共享资源锁定;如果资源被占用,就需要一定的等待阻塞唤醒机制来保证锁的分配。这个机制主要用的是 CLH 队列变体实现的,将暂时获取不到锁的线程加入到队列中。

CLH 队列

CLH(Craig, Landin, and Hagersten)队列是一种基于链表的自旋锁队列。AQS 使用 CLH 队列的变体来管理等待线程的队列。

AQS 的核心组成部分

AQS 最核心的就是三大部分:

  1. 状态(State)

    • AQS 使用一个 volatileint 类型的成员变量 state 来表示同步状态。state 的值可以表示锁的状态、信号量的计数等。
    • 通过 getState()setState()compareAndSetState() 方法来操作 state 的值。
  2. FIFO 队列

    • AQS 内置了一个 FIFO 队列来管理等待线程。当线程获取锁失败时,会被加入到这个队列中等待。
    • 队列中的每个节点代表一个等待线程,节点之间通过 prevnext 指针连接。
  3. 获取/释放操作(重写)

    • AQS 定义了获取和释放资源的方法,但具体的实现需要子类去重写。
    • 子类需要实现 tryAcquire()tryRelease()tryAcquireShared()tryReleaseShared() 等方法,来定义资源的获取和释放逻辑。

AQS 的工作原理

  1. 获取资源

    • 线程调用 acquire() 方法尝试获取资源。
    • 如果 tryAcquire() 返回 true,表示获取成功,线程继续执行。
    • 如果 tryAcquire() 返回 false,表示获取失败,线程会被加入到等待队列中,并进入阻塞状态。
  2. 释放资源

    • 线程调用 release() 方法释放资源。
    • 如果 tryRelease() 返回 true,表示释放成功,AQS 会唤醒等待队列中的一个或多个线程,让它们重新竞争资源。

以下是一个简单的自定义锁的示例,展示了如何使用 AQS 实现一个独占锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class CustomLock {
private final Sync sync = new Sync();

public void lock() {
sync.acquire(1);
}

public void unlock() {
sync.release(1);
}

private static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int acquires) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

@Override
protected boolean tryRelease(int releases) {
if (getState() == 0) throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
}
}

ThreadLocal 详解

ThreadLocal 是 Java 中为了线程安全设置的一种机制,每个线程可以设置局部变量,也就是允许设置自己线程的一个数据副本,线程对副本的修改不会影响到线程间的资源共享和同步问题。

ThreadLocal

ThreadLocal 的作用

  • 线程隔离:每个线程都有自己独立的 ThreadLocal 变量副本,线程之间的数据互不影响。
  • 降低耦合度:在同一个线程的多个函数或组件之间,使用 ThreadLocal 可以减少参数的传递,降低代码之间的耦合度,使得模块清晰化。
  • 性能优势:由于 ThreadLocal 避免了线程间的同步开销,所以大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。

ThreadLocal 的原理

ThreadLocal 的实现依赖于 Thread 类中的一个 ThreadLocalMap 字段,这是一个存储 ThreadLocal 变量本身和对应值的映射。每个线程都有自己的 ThreadLocalMap 实例,用于存储该线程所持有的所有 ThreadLocal 变量的值。

核心操作

  • get() 方法

    • 当调用 ThreadLocalget() 方法时,ThreadLocal 会检查当前线程的 ThreadLocalMap 中是否有与之关联的值。
    • 如果有,则返回值;如果没有,会调用 initialValue() 方法初始化该值,然后将其放入 ThreadLocalMap 中并返回。
  • set() 方法

    • 当调用 set() 方法时,ThreadLocal 会将当前线程与给定的值关联起来,即向 ThreadLocalMap 中存入键值对,键为当前 ThreadLocal 对象本身,值为给定的值。
  • remove() 方法

    • 当调用 remove() 方法时,ThreadLocal 会从当前线程的 ThreadLocalMap 中移除与当前 ThreadLocal 对象关联的键值对。

可能存在的问题

内存泄漏问题

  • 原因ThreadLocalMap 中的 Entry 对象持有对 ThreadLocal 对象的强引用,如果 ThreadLocal 对象没有被显式移除,即使线程结束,Entry 对象仍然存在,导致 ThreadLocal 对象无法被垃圾回收。
  • 解决方法:在不再需要 ThreadLocal 变量时,显式调用 remove() 方法,确保 ThreadLocal 对象能够被及时回收。

以下是一个简单的示例代码,展示了 ThreadLocal 的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ThreadLocalExample {
private static final ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

public static void main(String[] args) {
Runnable task = () -> {
int value = threadLocal.get();
System.out.println(Thread.currentThread().getName() + " initial value: " + value);
threadLocal.set(value + 1);
System.out.println(Thread.currentThread().getName() + " updated value: " + threadLocal.get());
threadLocal.remove(); // 显式移除 ThreadLocal 变量
};

Thread thread1 = new Thread(task, "Thread-1");
Thread thread2 = new Thread(task, "Thread-2");

thread1.start();
thread2.start();
}

乐观锁/悲观锁

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法),如果没有被修改,则更新资源;如果被修改,则重试操作。

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

Java 实现乐观锁的方式

在 Java 中,实现乐观锁的方式主要有以下几种:

  1. CAS(Compare-and-Swap)操作

CAS 是乐观锁的基础,Java 提供了原子类包(java.util.concurrent.atomic),包含各种原子变量类操作,这些类通过使用 CAS 操作方式,实现了线程安全的原子操作,可用来实现乐观锁。

以下是一个使用 AtomicInteger 实现乐观锁的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.concurrent.atomic.AtomicInteger;

public class CASExample {
private AtomicInteger value = new AtomicInteger(0);

public boolean updateValue(int expectedValue, int newValue) {
return value.compareAndSet(expectedValue, newValue);
}

public int getValue() {
return value.get();
}
}

2. 版本号控制

增加一个字段记录更新的版本,每次更新递增版本号。在更新时,同时比较版本号,如果一致则替换更新完成,若不一致则更新失败。

以下是一个使用版本号控制实现乐观锁的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class VersionControlExample {
private int version = 0;
private int data = 0;

public boolean updateData(int expectedVersion, int newData) {
if (version == expectedVersion) {
data = newData;
version++;
return true;
}
return false;
}

public int getData() {
return data;
}

public int getVersion() {
return version;
}
}
  1. 时间戳

使用时间戳记录更新时的时间,每次更新时比较时间戳,如果一致则替换更新完成,若不一致则更新失败。

以下是一个使用时间戳实现乐观锁的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.Date;

public class TimestampExample {
private Date timestamp = new Date();
private int data = 0;

public boolean updateData(Date expectedTimestamp, int newData) {
if (timestamp.equals(expectedTimestamp)) {
data = newData;
timestamp = new Date();
return true;
}
return false;
}

public int getData() {
return data;
}

public Date getTimestamp() {
return timestamp;
}
}

CAS 的缺点

CAS(Compare-and-Swap)操作是实现乐观锁的基础,但它也存在一些缺点,主要包括以下三个方面:

  1. ABA 问题

ABA 问题是指在 CAS 操作中,一个变量在操作过程中经历了从 A 到 B 再回到 A 的变化,而 CAS 操作只比较变量的当前值和预期值是否一致,无法检测到这种中间变化。

解决方案

  • 版本号:在变量中增加一个版本号字段,每次更新时递增版本号,CAS 操作同时比较变量值和版本号。
  • 时间戳:使用时间戳记录变量的更新时间,CAS 操作同时比较变量值和时间戳。
  1. 循环时间过长

若 CAS 无法更新成功,线程会一直自旋(循环),长时间占用 CPU 资源,带来无用的花销。这也就造成了不能所有锁都使用CAS。

解决方案

  • 自旋次数限制:设置自旋次数上限,超过次数后放弃自旋,进入阻塞状态。
  • 自旋时间限制:设置自旋时间上限,超过时间后放弃自旋,进入阻塞状态。
  1. 只能保证一个共享变量的原子性

CAS 操作只能保证单个共享变量的原子性,无法保证多个共享变量的原子性。

解决方案

  • 锁机制:使用 synchronizedReentrantLock 等锁机制,保证多个共享变量的原子性。
  • 组合操作:将多个共享变量封装在一个对象中,使用 AtomicReference 进行 CAS 操作。

volatile

volatile 是 Java 中的一个关键字,主要用于修饰变量。它具有两个主要作用:保证变量对所有线程的可见性和禁止指令重排序优化。

保证变量对所有线程的可见性

当一个变量被声明为 volatile 时,它会保证对这个变量的写操作会立即刷新到主存中,而对这个变量的读操作会直接从主存中读取,从而确保了多线程环境下对该变量访问的可见性。这意味着一个线程修改了 volatile 变量的值,其他线程能够立刻看到这个修改,不会受到各自线程工作内存的影响。

禁止指令重排序优化

volatile 关键字在 Java 中主要通过内存屏障来禁止特定类型的指令重排序。内存屏障分为以下几种:

  1. 写-写(Write-Write)屏障

    在对 volatile 变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到 volatile 写操作之后。

  2. 读-写(Read-Write)屏障

    在对 volatile 变量执行读操作之后,会插入一个读屏障。它确保了对 volatile 变量的读操作之后的所有普通读操作都不会被提前到 volatile 读之前执行,保证了读取到的数据是最新的。

  3. 写-读(Write-Read)屏障

    这是最重要的一个屏障,它发生在 volatile 写之后和 volatile 读之前。这个屏障确保了 volatile 写操作之前的所有内存操作(包括写操作)都不会被重排序到 volatile 读之后,同时也确保了 volatile 读操作之后的所有内存操作(包括读操作)都不会被重排序到 volatile 写之前。

volatile 不能完全保证线程安全

volatile 关键字在 Java 中主要用于保证变量对所有线程的可见性和禁止指令重排序优化。然而,volatile 并不能完全保证线程安全,因为它没有保证数据操作的原子性。在多线程环境下,进行复合操作(如自增、自减等,自增、自减包含读取、修改和写入三个步骤,可能会出现覆盖问题)时,volatile 无法保证操作的原子性,因此可能会导致线程安全问题。

保证线程安全的解决方案

为了保证复合操作的线程安全,可以使用以下方法:

  1. synchronized 关键字:使用 synchronized 关键字来同步代码块或方法,确保同一时刻只有一个线程能访问这些代码。

  2. Lock 接口和 ReentrantLock:使用 Lock 接口和 ReentrantLock 类来实现更灵活的锁机制,确保同一时刻只有一个线程能访问这些代码。

  3. 原子类:使用 java.util.concurrent.atomic 包中的原子类(如 AtomicInteger)来实现原子操作,确保操作的原子性。

指令重排的原理

在执行程序时,为了提高性能,处理器和编译器往往会对指令进行重排优化。指令重排的目的是在不改变程序运行结果的前提下,优化指令的执行顺序,以提高处理器的执行效率。

指令重排的原则

指令重排遵循以下两个原则:

  1. 不能改变程序的运行结果:指令重排不能改变单线程程序的运行结果。也就是说,无论指令如何重排,单线程程序的执行结果必须保持一致。

  2. 存在依赖关系的指令不能进行重排:如果两条指令之间存在数据依赖关系(即一条指令的执行结果会影响另一条指令的执行),那么这两条指令不能进行重排。

指令重排的类型

指令重排可以分为以下几种类型:

  1. 编译器重排:编译器在生成机器码时,可能会对指令进行重排,以优化代码的执行顺序。

  2. 处理器重排:处理器在执行指令时,可能会对指令进行重排,以提高指令流水线的效率。

指令重排的示例

以下是一个简单的示例代码,展示了指令重排的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class InstructionReorderingExample {
private static int a = 0;
private static int b = 0;

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
a = 1;
b = 2;
});

Thread t2 = new Thread(() -> {
int x = b;
int y = a;
System.out.println("x = " + x + ", y = " + y);
});

t1.start();
t2.start();
}
}

在这个示例中,t1 线程中的两条指令 a = 1b = 2 之间没有数据依赖关系,因此编译器和处理器可能会对这两条指令进行重排。重排后的执行顺序可能是 b = 2 先执行,a = 1 后执行。

指令重排的影响

指令重排在单线程环境下通常不会影响程序的运行结果,但在多线程环境下可能会导致不可预期的结果。例如,在上述示例中,如果 t2 线程在 t1 线程完成 a = 1 之前读取了 b 的值,那么 t2 线程可能会输出 x = 2, y = 0,这与预期的 x = 2, y = 1 不一致。

公平锁与非公平锁

在多线程编程中,锁机制是保证线程安全的重要手段。根据线程获取锁的顺序,锁可以分为公平锁和非公平锁。

公平锁

公平锁(Fair Lock)是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。

流程

  1. 获取锁: 线程尝试获取锁,若锁已被占用,则将自身加入等待队列队尾,并进入休眠状态。
  2. 释放锁: 持有锁的线程释放锁后,会唤醒等待队列队首的线程,该线程尝试获取锁。
  3. 状态切换: 线程在运行和休眠状态之间切换,每次切换都需要进行用户态和内核态的转换,这种转换开销较大,导致公平锁执行速度较慢。

非公平锁

非公平锁(Non-Fair Lock)是指多个线程加锁时直接尝试获取锁,能抢到锁的线程直接占有锁,抢不到才会到等待队列的队尾等待。

流程

  1. 获取锁: 线程尝试通过CAS操作直接获取锁,若成功则直接持有锁,无需进入等待队列。
  2. 竞争锁: 若CAS失败,线程才会进入等待队列,等待下次获取锁的机会。
  3. 效率提升: 非公平锁避免了线程频繁的休眠和唤醒操作,减少了用户态和内核态的切换开销,从而提高了程序执行效率。

对比

公平锁与非公平锁的优缺点对比:

特性 公平锁 非公平锁
获取锁顺序 严格按照等待队列顺序 不保证顺序,可能出现“插队”现象
吞吐量 较低,频繁的线程切换开销大 较高,减少了线程切换开销
响应时间 较长,新线程需要等待 较短,新线程有机会立即获取锁
饥饿问题 不易发生 可能发生,某些线程可能长时间无法获取锁
适用场景 对公平性要求较高,例如银行排队系统 对性能要求较高,例如高并发场景

Synchronized 是不公平锁,ReentrantLock 默认情况下也是不公平锁,但可以通过构造函数参数指定为公平锁(在其获取锁的方法中,设置前置判断条件 !hasQueuedPredecessors())。