阅读本文你可以获得 Synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock、Condition、Semaphore、CountDownLatch、CyclicBarrier、JMM、Volatile、Happens-Before。
全文共16000字左右(包含示例代码)、欢迎收藏、在看、转发分批食用
一、理解同步与并发
在学习锁之前,我们先了解一下同步与并发的概念,
在Java中,同步和并发是两个重要的概念。理解这两个概念对于学习锁机制非常重要。
同步是指多个线程在访问共享资源时,对资源进行同步化的操作,以确保它们不会发生冲突。当多个线程同时访问同一个共享资源时,如果没有进行同步处理,就会发生竞争条件的问题,导致程序出现不可预测的行为。为了解决这个问题,Java提供了多种同步机制,如synchronized关键字、ReentrantLock等。
并发是指在同一时间内同时执行多个线程的能力。Java中的线程是轻量级的进程,它们可以并发地执行,从而提高了程序的性能和响应速度。Java中的线程模型是基于共享内存的并发模型,多个线程共享同一块内存空间,它们可以通过读写共享变量来实现线程间的通信和交互。
多个线程同时访问共享资源时,就会发生并发问题,如线程安全、死锁等。为了解决这些问题,需要使用Java中提供的同步机制来保证线程的安全性和正确性。
因此,在学习锁机制之前,需要理解Java中的同步和并发的概念,以及多个线程同时访问共享资源时可能出现的问题。这样才能更好地理解锁机制的作用和原理。
二、锁的基本原理
防止竞争条件,保证线程安全,可见性,避免死锁
锁的基本原理是防止竞争条件,保证线程安全性和可见性,避免死锁等问题。下面是关于锁的基本原理的详细介绍:
- 防止竞争条件
当多个线程同时访问共享资源时,可能会发生竞争条件。竞争条件是指当多个线程同时执行同一段代码时,由于执行顺序的不同而导致结果的不确定性。
锁的作用就是在多个线程访问共享资源时保证同一时刻只有一个线程访问,从而避免竞争条件的发生。当一个线程获取到锁时,其他线程必须等待锁的释放才能继续访问共享资源。
- 保证线程安全性和可见性
线程安全性和可见性是Java并发编程中非常重要的概念。线程安全性是指当多个线程同时访问共享资源时,不会出现数据损坏或程序崩溃等问题。可见性是指当一个线程修改了共享资源时,其他线程能够立即看到这个修改。
锁机制可以保证线程安全性和可见性。当一个线程获取到锁时,其他线程无法修改共享资源,从而避免了数据损坏和程序崩溃等问题。而锁机制也可以保证共享资源的可见性,因为当一个线程释放锁时,其他线程能够立即看到共享资源的最新状态。
- 避免死锁
死锁是指两个或多个线程相互等待对方释放锁,从而导致程序无法继续执行的情况。死锁是Java并发编程中一个非常严重的问题,必须避免发生。
为了避免死锁,必须采取一些策略,例如避免嵌套锁、避免长时间占用锁、按照相同的顺序获取锁等。另外,还可以使用专门的工具来检测和避免死锁,例如死锁检测器和避免死锁算法等。
总之,锁机制是Java并发编程中非常重要的一部分,了解锁的基本原理,包括如何防止竞争条件、如何保证线程安全性和可见性,以及如何避免死锁等问题,对于编写高效、可靠的并发程序非常有帮助。
三、Java中的锁类型
synchronized,reentrantlock,reentrantReadWriteLock,stampedLock,ReadWriteLock
无锁、偏向所、轻量级锁、重量级锁
Semaphore、CountDownLatch、CyclicBarrier
等等。。。
Java中提供了多种锁机制,每种锁机制都有其特点和适用场景。下面是这些锁机制的简要介绍:
3.1、synchronized关键字锁
synchronized关键字是Java中最基本和最常用的锁机制。使用synchronized关键字可以将一段代码块或一个方法标记为同步代码块,以保证在任何时刻最多只能有一个线程执行它们。synchronized锁是Java内置锁的一种实现。
- 同步方法:可以使用synchronized关键字修饰方法,将整个方法声明为同步方法。同步方法会对整个方法体进行加锁,确保同一时间只有一个线程可以执行该方法。
- 同步代码块:可以使用synchronized关键字修饰代码块,将特定的代码块声明为同步代码块。同步代码块使用指定的对象作为锁,在执行代码块时获取锁,确保同一时间只有一个线程可以执行该代码块。
- 锁对象:synchronized关键字需要一个锁对象来实现同步。对于同步方法,锁对象是该方法所属对象(this);对于同步代码块,可以通过指定一个对象来作为锁。多个线程在获取同一个锁对象时才会互斥执行。
- 内置锁(Intrinsic Lock):synchronized关键字使用的是Java中的内置锁,也称为监视器锁。每个对象都有一个内置锁,当线程获取到内置锁时,其他线程需要等待该锁释放才能访问同步代码。
- 互斥性:synchronized关键字保证同一时间只有一个线程可以获取到锁,从而实现互斥访问共享资源,避免数据竞争和不一致性。
- 可重入性:同一个线程可以多次获取同一个锁而不会产生死锁,这种特性称为可重入性。即在同一个线程中,可以嵌套调用同步方法或同步代码块,而不会因为重复获取锁而造成阻塞。
- 非公平性:synchronized关键字默认采用非公平锁的方式,即多个线程竞争锁时,没有特定的顺序。但可以通过使用ReentrantLock类来实现公平锁。
3.1.1、底层原理
在Java中,synchronized关键字的底层原理涉及到对象头(Object Header)和监视器(Monitor)的概念。
每个Java对象都有一个对象头,对象头包含一些用于对象管理的元数据信息,其中之一是用于实现同步的锁信息。当一个对象被synchronized关键字修饰时,对象头中的锁信息被用于实现同步操作。
当线程进入一个synchronized方法或代码块时,它首先会尝试获取对象的锁。如果锁是可用的,即没有其他线程持有该锁,那么当前线程会成功获取锁并继续执行同步代码。在获取锁时,Java会将锁的计数器加1,表示当前线程获得了锁。
如果锁已经被其他线程持有,那么当前线程就会进入阻塞状态,直到获取到锁为止。在等待期间,该线程会被放置在对象的等待集(Wait Set)中,等待其他线程释放锁并通知它可以继续执行。这种等待和唤醒的机制由监视器来管理。
监视器是一种同步机制,用于管理对象的锁和线程的等待集。每个Java对象都与一个监视器相关联,监视器包含了与该对象关联的锁和等待集。只有持有锁的线程才能执行同步代码,其他线程必须等待锁的释放。
当线程执行完synchronized方法或代码块中的代码后,会释放锁,并将锁的计数器减1。如果计数器变为0,表示当前线程完全释放了锁,其他线程可以竞争获取该锁。
总结来说,synchronized关键字的底层原理是通过对象的锁信息和监视器来实现线程的互斥访问和同步操作。它利用了对象头中的锁信息以及监视器的等待集(Wait Set)来管理线程的锁获取和释放。这种机制确保了同一时间只有一个线程能够执行synchronized代码块或方法,从而保证了线程安全性。
3.1.1.1、对象头
在JDK 8中,Java虚拟机(JVM)的对象头信息包括以下几个部分的组成:
- Mark Word(标记字段):Mark Word是对象头信息中最重要的部分,占用64位(在64位JVM中)。它包含了一些标志位和存储对象相关的信息。其中可能包含锁状态、GC相关标记、哈希码等信息。
- Class Metadata Address(类元数据指针):对象头还包含一个指向对象所属类的元数据的指针。这个指针指向方法区中的类元数据,用于确定对象的类型信息,包括类的方法、字段等。
- Array Length(数组长度):如果对象是一个数组对象,对象头中会包含数组长度的信息。这样可以在运行时快速获取数组的长度。
- Biased Locking Information(偏向锁信息):JDK 6及之后的版本引入了偏向锁优化技术,用于提高无竞争情况下的同步性能。对象头中可能包含偏向锁的相关信息,用于标记对象是否处于偏向锁状态。
需要注意的是,对象头的具体组成可能因不同的JVM实现、JVM版本或对象的状态而有所不同。上述组成是通常情况下的一般概念,实际的实现细节可能会有所变化。
在JDK 8中,Mark Word(标记字段)中的标记锁状态信息使用不同的位来进行标记。具体的标记位包括以下几个:
-
锁状态(Lock State):标记字段中的几个位用于表示对象的锁状态。其中,最低两位用于表示锁状态,可以有以下几种状态:
- 00:无锁状态(Unlocked):对象未被任何线程锁定。
- 01:偏向锁状态(Biased Lock):对象被某个线程偏向锁定。
- 10:轻量级锁状态(Lightweight Lock):对象被某个线程轻量级锁定。
- 11:重量级锁状态(Heavyweight Lock):对象被某个线程重量级锁定。
- 偏向线程ID(Thread ID):如果对象处于偏向锁状态(01),那么标记字段的一部分用于存储持有偏向锁的线程的ID。这样可以快速判断当前线程是否可以获取偏向锁。
- 偏向时间戳(Epoch):在JDK 8中,偏向锁引入了一个偏向时间戳,用于标记偏向锁的持续时间。当偏向锁的时间戳过期时,将会撤销偏向锁。
需要注意的是,具体的标记位布局和使用可能会因JVM实现和配置参数而有所不同。上述标记方式是一种常见的实现方式,但并不是绝对的规范。JVM的具体实现可以根据需求进行优化和调整。
标记字段的使用是为了支持JVM的锁优化技术,如偏向锁、轻量级锁和重量级锁,以提高同步操作的性能和效率。
在Java中,锁状态的转换是由JVM自动处理的,根据线程的竞争情况和同步操作的结果来进行状态转换。以下是几种常见的锁状态转换方式:
- 无锁状态到偏向锁状态:当一个线程第一次访问一个对象时,对象处于无锁状态。如果JVM启用了偏向锁优化并且该线程获取到了锁,那么对象的状态会转换为偏向锁状态,同时标记字段中的偏向线程ID会记录当前线程的ID。
- 偏向锁状态到轻量级锁状态:如果一个线程在偏向锁状态下访问一个对象时,另一个线程也想要获取该对象的锁,那么偏向锁会被撤销,对象的状态会转换为轻量级锁状态。在这种情况下,JVM会尝试使用CAS(比较并交换)操作来尝试获取锁,如果成功则对象的状态转换为轻量级锁状态。
- 轻量级锁状态到重量级锁状态:如果轻量级锁获取锁的过程中发生竞争(即另一个线程也想要获取锁),那么轻量级锁会升级为重量级锁。升级为重量级锁的过程包括锁膨胀(Lock Inflation)和互斥同步(Mutex Synchronization),这样所有竞争的线程都需要通过互斥同步来获取锁。
- 重量级锁状态到无锁状态:当持有重量级锁的线程释放锁时,对象的状态会转换回无锁状态,等待其他线程再次竞争获取锁。
需要注意的是,锁状态的转换是由JVM内部自动管理的,程序员无需显式干预。JVM会根据竞争情况、同步操作的结果以及具体的锁优化策略来动态决定锁状态的转换。锁状态的转换是为了提高同步操作的性能和效率,减少竞争带来的性能损失。
3.1.1.2、监视器
监视器(Monitor)是Java中用于实现同步的基本机制,用于保护对象的互斥访问。每个Java对象都与一个监视器相关联,包括以下几个主要组成部分:
- 互斥锁(Mutex Lock):互斥锁是监视器的核心部分,用于实现对象的互斥访问。互斥锁是一个二进制状态变量,用于表示对象是否被锁定。它确保在任意时刻只有一个线程可以持有该对象的锁,其他线程必须等待锁的释放才能进入临界区。
- 等待集合(Wait Set):等待集合是一个线程的集合,用于存放因为等待对象锁而进入等待状态的线程。当线程调用对象的wait()方法时,它会被放入等待集合中,释放对象的锁,并进入等待状态。只有当其他线程调用notify()或notifyAll()方法时,等待集合中的线程才会被唤醒。
- 条件队列(Condition Queue):条件队列是一种用于线程通信的机制。每个监视器都可以关联一个或多个条件队列。线程可以调用条件队列的await()方法进入等待状态,并在满足特定条件时被唤醒。当线程被唤醒时,它会重新尝试获取对象的锁。
- 计数器(Counters):监视器中通常会包含一些计数器,用于记录等待线程的数量、锁重入的次数等信息。
这些组成部分共同构成了监视器的基本结构,实现了线程的同步和互斥访问。它们允许线程在临界区内操作共享资源时按照一定的顺序访问,并确保线程间的互斥性和协调性。监视器的实现和具体细节由JVM负责,开发人员可以使用synchronized关键字或显式地调用监视器相关的方法来实现对象的同步。
3.2、ReentrantLock锁
ReentrantLock是一个可重入的互斥锁,提供了比synchronized更多的高级特性,如公平锁和非公平锁、可中断锁、条件变量等。ReentrantLock锁是显式锁的一种实现。
ReentrantLock 是 Java 中提供的一种可重入锁的实现。它是一个基于显示锁的类,提供了比 synchronized 关键字更多的灵活性和功能。ReentrantLock 类提供了 lock() 和 unlock() 方法来获取和释放锁,以及其他一些用于管理锁状态的方法。
可重入锁是指一个线程在获取了锁之后,可以再次获取该锁而不会造成死锁。也就是说,线程可以多次进入同一个锁,而不会被自己所持有的锁所阻塞。
公平锁和非公平锁是指在多个线程等待锁时,锁的获取顺序是否符合线程的请求顺序。ReentrantLock 提供了构造函数来指定锁的公平性,默认情况下是非公平锁。公平锁会按照线程请求的顺序来获取锁,而非公平锁则允许线程插队获取锁。
使用 ReentrantLock 类,可以通过调用 lock() 方法来获取锁,并通过调用 unlock() 方法来释放锁。在获得锁之前,线程会被阻塞,直到锁可用。当线程释放锁后,其他线程才有机会获取该锁。
Condition 接口提供了对锁的条件等待和唤醒机制的支持。ReentrantLock 类中的 newCondition() 方法可以创建一个与该锁关联的 Condition 实例。通过 Condition,线程可以在某个条件不满足时等待,并在条件满足时被唤醒。
ReentrantLock 使用一个重入计数器来跟踪线程对锁的重入次数。当线程重入锁时,计数器会递增。只有当线程完全释放锁时,计数器才会递减。这样可以确保同一个线程多次获取锁而不会引发死锁。
3.2.1、原理
ReentrantLock(可重入锁)的原理是基于独占锁(Exclusive Locking)的概念。它使用一种称为AQS(AbstractQueuedSynchronizer,抽象队列同步器)的同步框架来实现。
AQS 是一个用于构建锁和其他同步器的框架,它提供了一个等待队列来管理等待获取锁的线程,并通过内置的 CAS(Compare and Swap)操作来实现对锁的获取和释放。
ReentrantLock 内部维护了一个状态变量,表示锁的状态,可以是被某个线程持有或者空闲状态。当一个线程请求获取锁时,如果锁处于空闲状态,该线程就可以直接获取到锁,然后将锁的状态设置为被该线程持有。如果锁处于被其他线程持有的状态,请求线程会被放入等待队列中,等待锁的释放。
在 ReentrantLock 中,当一个线程重复获取同一个锁时(即重入锁),锁的状态会递增,而不会造成死锁。每次释放锁时,锁的状态递减,直到锁的状态为0,表示锁完全释放,其他等待线程可以获取锁。
ReentrantLock 还支持公平性和非公平性。在公平锁模式下,等待时间较长的线程会有更高的获取锁的优先级,而在非公平锁模式下,线程可以通过抢占方式获取锁,不考虑等待时间。
总结来说,ReentrantLock 的原理是通过 AQS 框架实现对锁的获取和释放,使用状态变量来表示锁的状态,支持重入特性,同时提供了公平性和非公平性的选择。这使得 ReentrantLock 在多线程环境下提供了可靠的同步机制。
3.2.2、AQS
AbstractQueuedSynchronizer(AQS)是Java中用于构建锁和同步器的抽象框架。它提供了一个基于等待队列的机制,用于管理多线程对共享资源的访问。
AQS 提供了一种基于独占模式和共享模式的同步器抽象。独占模式意味着只有一个线程可以持有锁,而共享模式允许多个线程同时访问资源。
AQS 的核心思想是使用一个状态变量来表示同步器的状态,并使用 CAS(Compare and Swap)操作来进行状态的更新和线程的排队。AQS 内部维护了一个等待队列,用于存储等待获取同步器的线程。线程在获取同步器时,如果发现同步器已被占用,则会被放入等待队列中,进入等待状态。
具体来说,当一个线程尝试获取同步器时,AQS 会使用 CAS 操作来竞争获取同步器的状态。如果获取成功,线程就可以继续执行;如果获取失败,AQS 会将线程加入等待队列,并使线程进入等待状态。当同步器被释放时,AQS 会从等待队列中选择一个线程来获取同步器,并将其从等待状态转换为运行状态。
AQS 还提供了一些方法供子类实现,例如 tryAcquire()、tryRelease() 等,用于控制同步器的获取和释放。子类可以根据具体的需求实现这些方法来实现自定义的同步逻辑。
AQS 是 Java 并发包中很多同步器的基础,包括 ReentrantLock、CountDownLatch、Semaphore 等。通过使用 AQS,开发人员可以更轻松地构建自定义的同步器,以实现线程间的协调和资源的安全访问。
3.2.3、示例
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class MySync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 1L;
@Override
protected boolean tryAcquire(int arg) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
if (getState() == 0)
throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
public void lock() {
acquire(1);
}
public void unlock() {
release(1);
}
public boolean isLocked() {
return isHeldExclusively();
}
}
public class CustomSyncExample {
private static MySync sync = new MySync();
public static void main(String[] args) {
Runnable task = () -> {
System.out.println("Thread " + Thread.currentThread().getId() + " is trying to acquire the lock.");
sync.lock();
System.out.println("Thread " + Thread.currentThread().getId() + " has acquired the lock.");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
sync.unlock();
System.out.println("Thread " + Thread.currentThread().getId() + " has released the lock.");
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
}
}
在上面的代码中,我们创建了一个名为 MySync
的自定义同步器,它继承了 AbstractQueuedSynchronizer
。在 MySync
中,我们实现了 tryAcquire()
和 tryRelease()
方法来控制同步器的获取和释放。lock()
方法和 unlock()
方法调用了 acquire()
和 release()
方法,用于获取和释放锁。
在 CustomSyncExample
类中,我们创建了两个线程,每个线程都会尝试获取同步器的锁。当一个线程成功获取锁后,它会进入临界区并执行一些操作,然后释放锁。另一个线程在第一个线程释放锁之前,会被阻塞等待。
输出结果类似于以下内容:
Thread 11 is trying to acquire the lock.
Thread 12 is trying to acquire the lock.
Thread 11 has acquired the lock.
Thread 11 has released the lock.
Thread 12 has acquired the lock.
Thread 12 has released the lock.
3.3、ReentrantReadWriteLock锁
ReentrantReadWriteLock是Java中的读写锁机制,它允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。Java中提供的ReentrantReadWriteLock实现了ReadWriteLock接口。
相比于独占锁(比如 ReentrantLock),读写锁在并发读取场景下可以提供更高的吞吐量。这是因为多个线程可以同时读取共享资源,而互不干扰,从而提高了并发性能。但是,在写入场景下,仍然只允许一个线程写入共享资源,以保证数据的一致性。
ReentrantReadWriteLock 内部包含两个锁:读锁和写锁。多个线程可以同时获取读锁,但只有一个线程可以获取写锁。当写锁被持有时,其他线程无法获取读锁或写锁。当读锁被持有时,其他线程仍然可以获取读锁,但不能获取写锁。
读写锁的重入性是指一个线程可以多次获取读锁或写锁,而不会造成死锁。也就是说,线程可以在持有读锁的情况下再次获取读锁,或者在持有写锁的情况下再次获取写锁。
ReentrantReadWriteLock 提供了以下几个重要的方法:
-
readLock()
:返回一个读锁对象,用于获取读锁。 -
writeLock()
:返回一个写锁对象,用于获取写锁。 -
readLock().lock()
:获取读锁。 -
writeLock().lock()
:获取写锁。 -
readLock().unlock()
:释放读锁。 -
writeLock().unlock()
:释放写锁。
读锁和写锁的获取和释放方式与普通锁(如 ReentrantLock)相似。通过使用读写锁,我们可以根据具体需求来控制对共享资源的读取和写入操作,提高并发性能和数据一致性
3.4、StampedLock锁
StampedLock是Java8中引入的一种乐观锁机制,与ReadWriteLock类似,它也允许多个线程同时访问共享资源,但与ReadWriteLock不同的是,它使用一个stamp值来标记当前锁状态,而不是使用读锁和写锁的状态。
它提供了三种模式的访问:读模式、写模式和乐观读模式。
与传统的读写锁不同,StampedLock 并没有严格的互斥关系,而是允许多个线程同时读取共享资源,以提高并发性能。写模式下,仍然只允许一个线程写入共享资源,以保证数据的一致性。
StampedLock 使用一个名为 stamp 的标记来表示锁的状态。在读锁和写锁的获取操作中,会返回一个 stamp 值,用于后续的操作。通过比较 stamp 的值,我们可以判断锁的状态是否发生变化。
StampedLock 提供了以下几个重要的方法:
-
readLock()
:返回一个读锁对象,用于获取读锁。 -
writeLock()
:返回一个写锁对象,用于获取写锁。 -
tryOptimisticRead()
:尝试获取乐观读锁,返回一个非负的 stamp 值。 -
validate()
:校验乐观读锁的 stamp 值是否仍然有效。 -
tryConvertToWriteLock()
:尝试将乐观读锁转换为写锁,成功则返回一个非负的 stamp 值,失败则返回零。 -
unlockRead()
:释放读锁。 -
unlockWrite()
:释放写锁。
StampedLock 的特性是在乐观读锁下,读操作不会阻塞写操作。当需要进行写操作时,如果乐观读锁成功,可以直接将乐观读锁升级为写锁,避免了阻塞其他读线程的情况。但如果乐观读锁失败,需要转换为普通的读锁或写锁来保证数据一致性。
使用 StampedLock 需要注意的是,它并不是可重入锁,也不支持条件变量。在使用过程中,需要谨慎处理锁的获取和释放,以避免死锁或其他并发问题。
StampedLock 提供了一种灵活且高效的读写锁机制,可以根据具体场景的需求来选择不同的访问模式,以平衡并发性能和数据一致性的需求。
3.4.1、示例
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock lock = new StampedLock();
public void set(double x, double y) {
long stamp = lock.writeLock();
try {
this.x = x;
this.y = y;
} finally {
lock.unlockWrite(stamp);
}
}
public double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
public class StampedLockExample {
public static void main(String[] args) {
Point point = new Point();
point.set(3.0, 4.0);
Runnable readTask = () -> {
double distance = point.distanceFromOrigin();
System.out.println("Distance from origin: " + distance);
};
Runnable writeTask = () -> {
point.set(5.0, 12.0);
System.out.println("Point coordinates updated.");
};
Thread t1 = new Thread(readTask);
Thread t2 = new Thread(writeTask);
t1.start();
t2.start();
}
}
在上面的示例中,我们创建了一个名为 Point
的类,其中包含了两个坐标 x
和 y
,以及一个 StampedLock
对象用于保护这些坐标的访问。
set()
方法使用写锁来更新坐标的值,它首先获取写锁,然后修改 x
和 y
的值,最后释放写锁。
distanceFromOrigin()
方法使用乐观读锁来计算点到原点的距离。它首先尝试获取乐观读锁,并在获取后拷贝 x
和 y
的值。然后,使用 validate()
方法验证乐观读锁的有效性。如果验证失败(锁状态发生变化),则使用读锁重新获取 x
和 y
的值,最后释放读锁。
在 main()
方法中,我们创建了两个线程,一个线程用于读取点到原点的距离,另一个线程用于更新点的坐标。通过使用 StampedLock,多个线程可以同时读取点的坐标,而在更新坐标时会阻塞其他读线程,以保证数据的一致性。
输出结果类似于以下内容:
Point coordinates updated.
Distance from origin: 5.0
3.5、Condition条件
在 Java 锁中,Condition
接口用于提供对锁的条件等待和唤醒机制。它是与特定锁关联的,用于在特定条件不满足时挂起线程,并在条件满足时唤醒等待的线程。
Condition
接口中定义了以下主要方法:
-
await()
:在当前线程等待,并释放关联的锁,直到接收到信号或被中断。 -
awaitUninterruptibly()
:与await()
类似,但不响应中断。 -
awaitNanos(long nanosTimeout)
:在当前线程等待指定的纳秒数,直到接收到信号、被中断或超时。 -
awaitUntil(Date deadline)
:在当前线程等待直到指定的绝对时间,直到接收到信号、被中断或超时。 -
signal()
:唤醒一个等待中的线程。 -
signalAll()
:唤醒所有等待中的线程。
Condition
的作用是使线程能够在特定的条件下等待和唤醒。它允许线程按照某种条件进行等待,而不是简单地阻塞在锁上。通过将线程挂起和唤醒的责任交给 Condition
,可以更加灵活地控制线程的执行顺序和互斥性。
Condition
常与 ReentrantLock
或 ReentrantReadWriteLock
一起使用。通过调用 lock
对象的 newCondition()
方法,可以创建一个与该锁关联的 Condition
实例。然后,可以使用 await()
方法在条件不满足时等待,使用 signal()
方法唤醒等待中的线程。
例如,在生产者-消费者模型中,生产者线程可以在队列满时调用 await()
方法等待,直到有空间可用。消费者线程可以在队列为空时调用 await()
方法等待,直到有数据可用。当生产者向队列添加数据或消费者从队列取出数据时,可以调用 signal()
方法唤醒相应的线程,以实现线程间的协调和同步。
通过使用 Condition
,可以更精确地控制线程的等待和唤醒,提供更高级的线程同步机制,以满足复杂的线程交互需求。
3.5.1、示例
生产者消费者模型
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ProducerConsumer {
private Queue queue = new LinkedList();
private int maxSize = 5;
private ReentrantLock lock = new ReentrantLock();
private Condition producerCondition = lock.newCondition();
private Condition consumerCondition = lock.newCondition();
public void produce() throws InterruptedException {
lock.lock();
try {
while (queue.size() == maxSize) {
// 队列已满,生产者等待
producerCondition.await();
}
int item = (int) (Math.random() * 100);
queue.add(item);
System.out.println("Produced item: " + item);
consumerCondition.signal(); // 唤醒一个消费者线程
} finally {
lock.unlock();
}
}
public void consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
// 队列为空,消费者等待
consumerCondition.await();
}
int item = queue.remove();
System.out.println("Consumed item: " + item);
producerCondition.signal(); // 唤醒一个生产者线程
} finally {
lock.unlock();
}
}
}
public class ProducerConsumerExample {
public static void main(String[] args) {
ProducerConsumer producerConsumer = new ProducerConsumer();
Runnable producerTask = () -> {
try {
while (true) {
producerConsumer.produce();
Thread.sleep(1000); // 生产者休眠一秒
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Runnable consumerTask = () -> {
try {
while (true) {
producerConsumer.consume();
Thread.sleep(1000); // 消费者休眠一秒
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread producerThread = new Thread(producerTask);
Thread consumerThread = new Thread(consumerTask);
producerThread.start();
consumerThread.start();
}
}
在上面的示例中,我们创建了一个名为 ProducerConsumer
的类,它维护了一个最大容量为 5 的队列作为共享资源。生产者线程使用 produce()
方法向队列中添加随机生成的项目,消费者线程使用 consume()
方法从队列中消费项目。
在 produce()
方法中,我们首先获取 ReentrantLock
对象的锁,并使用 while
循环检查队列是否已满。如果队列已满,生产者线程将通过调用 producerCondition.await()
方法进入等待状态。当队列有空间可用时,生产者线程生成一个随机项目,并将其添加到队列中。随后,我们调用 consumerCondition.signal()
方法唤醒一个等待中的消费者线程。最后,我们释放锁。
在 consume()
方法中,我们也首先获取锁,并使用 while
循环检查队列是否为空。如果队列为空,消费者线程将通过调用 consumerCondition.await()
方法进入等待状态。当队列中有项目可用时,消费者线程从队列中移除一个项目,并打印出来。随后,我们调用 producerCondition.signal()
方法唤醒一个等待中的生产者线程。最后,我们释放锁。
在 main()
方法中,我们创建了一个 ProducerConsumer
实例,并分别创建了一个生产者线程和一个消费者线程来执行相应的任务。最后,我们启动这两个线程,并让它们运行。生产者线程每秒产生一个项目,消费者线程每秒消费一个项目。
3.6、Semaphore信号量
Semaphore
(信号量)是 Java 中的一个并发控制工具,用于管理对共享资源的访问。它维护了一个计数器,该计数器表示可用的许可数量。线程可以通过获取和释放许可来控制对共享资源的访问。
Semaphore
提供了以下主要方法:
-
Semaphore(int permits)
:创建一个Semaphore
对象,并指定初始的许可数量。 -
void acquire()
:获取一个许可,如果没有可用的许可,则线程将阻塞等待。 -
void release()
:释放一个许可,将其返回给Semaphore
,以便其他线程可以获取它。 -
int availablePermits()
:获取当前可用的许可数量。 -
boolean tryAcquire()
:尝试获取一个许可,如果成功获取到许可,则返回true
,否则返回false
。
3.6.1、示例
Semaphore
的用法如下:
- 创建一个
Semaphore
对象,并指定许可的数量。 - 在需要访问共享资源的线程中,调用
acquire()
方法获取一个许可。如果没有可用的许可,线程将被阻塞,直到有许可可用。 - 当线程完成对共享资源的访问后,调用
release()
方法释放许可,以便其他线程可以获取它。 - 可以通过调用
availablePermits()
方法获取当前可用的许可数量。 - 可以使用
tryAcquire()
方法尝试获取许可,如果成功获取到许可,则返回true
,否则返回false
,不会阻塞线程。
下面是一个使用 Semaphore
的简单示例,展示了如何限制同时访问某个资源的线程数量:
import java.util.concurrent.Semaphore;
public class SharedResource {
private Semaphore semaphore = new Semaphore(3); // 最多允许3个线程同时访问
public void accessResource() {
try {
semaphore.acquire(); // 获取一个许可
System.out.println(Thread.currentThread().getName() + "正在访问共享资源");
Thread.sleep(2000); // 模拟访问共享资源的耗时操作
System.out.println(Thread.currentThread().getName() + "访问共享资源结束");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放许可
}
}
}
public class SemaphoreExample {
public static void main(String[] args) {
SharedResource sharedResource = new SharedResource();
// 创建多个线程访问共享资源
for (int i = 1; i sharedResource.accessResource());
thread.start();
}
}
}
在上述示例中,我们创建了一个 SharedResource
类,其中的 accessResource()
方法模拟了对共享资源的访问。在访问资源之前,线程会调用 semaphore.acquire()
方法获取一个许可,如果没有可用的许可,线程将被阻塞等待。访问资源结束后,线程调用 semaphore.release()
方法释放许可,以便其他线程可以获取它。
在 main()
方法中,我们创建了一个 SharedResource
实例,并创建了多个线程来访问共享资源。通过使用 Semaphore
,我们限制了同时访问资源的线程数量为 3。这意味着最多只有 3 个线程可以同时访问共享资源,其他线程需要等待许可的释放。
3.7、CountDownLatch计数器
CountDownLatch是一种常用的并发工具,它可以让一个或多个线程等待其他线程执行完毕后再进行操作。
3.7.1、原理
CountDownLatch
的原理是基于一个计数器实现的。计数器的初始值由用户指定,每当一个线程完成一定的任务后,计数器的值就会减1。当计数器的值达到零时,所有等待的线程将被唤醒。
具体实现原理如下:
- 创建
CountDownLatch
对象时,指定计数器的初始值。 - 当一个线程完成了一定的任务后,调用
countDown()
方法将计数器的值减1。 - 在调用
await()
方法的线程中,会进入等待状态,直到计数器的值为零。 - 每次调用
countDown()
方法都会减小计数器的值,并检查计数器的值是否已经达到零。 - 如果计数器的值为零,则所有等待的线程将被唤醒,继续执行。
CountDownLatch
的实现使用了 AQS
(AbstractQueuedSynchronizer)的同步机制。在内部,它使用一个共享的同步状态来表示计数器的值,并通过 acquireShared()
和 releaseShared()
方法实现线程的等待和唤醒操作。
当线程调用 await()
方法时,它会尝试通过 acquireShared()
方法获取同步状态。如果计数器的值为零,则获取操作将立即成功,线程可以继续执行。否则,线程将进入等待队列,等待其他线程调用 countDown()
方法来减小计数器的值。
当线程调用 countDown()
方法时,它会通过 releaseShared()
方法释放同步状态。释放操作将会减小计数器的值,并检查是否达到零。如果计数器的值为零,则会唤醒所有等待的线程,使它们从等待队列中被移出,并继续执行。
通过这种方式,CountDownLatch
实现了线程之间的协调和同步,允许一个或多个线程等待其他线程完成一定的任务后再继续执行。
CountDownLatch
提供了以下主要方法:
-
CountDownLatch(int count)
:创建一个CountDownLatch
对象,并指定初始计数值。 -
void countDown()
:将计数器的值减1。 -
void await()
:等待计数器的值达到零,即等待所有线程完成任务。
3.7.2、示例
- 建一个
CountDownLatch
对象,并指定初始计数值。 - 在每个线程中,完成一定的任务后,调用
countDown()
方法将计数器的值减1。 - 在主线程或需要等待的线程中,调用
await()
方法等待计数器的值达到零。
下面是一个简单示例,展示了如何使用 CountDownLatch
实现线程的协同工作:
import java.util.concurrent.CountDownLatch;
public class Worker implements Runnable {
private final CountDownLatch latch;
public Worker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " 正在执行任务");
Thread.sleep(2000); // 模拟任务执行时间
System.out.println(Thread.currentThread().getName() + " 任务执行完毕");
latch.countDown(); // 任务完成后,计数器减1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class CountDownLatchExample {
public static void main(String[] args) {
int numThreads = 3; // 3个工作线程
CountDownLatch latch = new CountDownLatch(numThreads);
// 创建工作线程
for (int i = 1; i
在上述示例中,我们创建了一个 Worker
类来模拟工作线程,每个工作线程执行一个耗时的任务。主线程创建了 3 个工作线程,并使用 CountDownLatch
来等待这些工作线程完成任务。
在每个工作线程的 run()
方法中,线程会执行任务并在任务完成后调用 countDown()
方法将计数器的值减1。
主线程通过调用 latch.await()
方法等待计数器的值达到零。当所有工作线程都完成任务并调用 countDown()
方法后,计数器归零,开始执行主线程下的任务。
3.8、CyclicBarrier循环屏障
CyclicBarrier
是 Java 中的一个同步辅助类,用于实现线程之间的同步和等待。它允许一组线程互相等待,直到所有线程都到达一个共同的屏障点,然后可以选择执行一个共同的动作。
3.8.1、原理
CyclicBarrier
的原理是基于一个屏障点和计数器实现的。计数器的初始值由用户指定,每个线程到达屏障点后会等待,直到所有线程都到达屏障点时,屏障点会打开,所有线程可以继续执行。
具体实现原理如下:
- 创建
CyclicBarrier
对象时,指定计数器的初始值和所有线程到达屏障点后需要执行的动作(可选)。 - 当一个线程到达屏障点时,调用
await()
方法,计数器的值会减1。 - 如果计数器的值变为零,表示所有线程都已经到达屏障点,屏障点会打开。
- 当屏障点打开后,所有等待的线程都会被唤醒,继续执行。
- 如果指定了共同的动作,那么所有线程在通过屏障点后会执行该动作,然后继续执行各自的任务。
3.8.2、示例
CyclicBarrier
的使用步骤如下:
- 创建一个
CyclicBarrier
对象,并指定计数器的初始值和需要执行的共同动作(可选)。 - 在需要同步的线程中,调用
await()
方法来到达屏障点,并等待其他线程。 - 当所有线程都到达屏障点后,屏障点打开,所有线程可以继续执行。
- 如果指定了共同动作,所有线程通过屏障点后会执行该动作,然后继续各自的任务。
下面是一个示例,展示了如何使用 CyclicBarrier
实现线程的同步和等待:
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
class Worker implements Runnable {
private final CyclicBarrier barrier;
public Worker(CyclicBarrier barrier) {
this.barrier = barrier;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " 执行任务");
Thread.sleep(2000); // 模拟任务执行时间
System.out.println(Thread.currentThread().getName() + " 任务执行完毕");
barrier.await(); // 等待其他线程
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}
}
public class CyclicBarrierExample {
public static void main(String[] args) {
int numThreads = 3; // 3个工作线程
Runnable barrierAction = () -> System.out.println("所有线程到达屏障点,开始执行共同动作");
CyclicBarrier barrier = new Cyclic
Barrier(numThreads, barrierAction);
// 创建工作线程
for (int i = 1; i
在上述示例中,我们创建了一个 Worker
类来模拟工作线程,每个工作线程执行一个耗时的任务。主线程创建了 3 个工作线程,并使用 CyclicBarrier
来实现它们的同步和等待。
在每个工作线程的 run()
方法中,线程会执行任务,并在任务完成后调用 barrier.await()
方法来到达屏障点,并等待其他线程。
当所有线程都到达屏障点后,指定的共同动作(如果有)会被执行,然后所有线程可以继续各自的任务。在本示例中,共同动作是输出一条消息。
通过使用 CyclicBarrier
,我们可以实现线程的同步和等待,确保多个线程在到达屏障点后同时继续执行,从而协调线程之间的操作。
3.9、CountDownLatch与CyclicBarrier区别
CountDownLatch
和 CyclicBarrier
都是 Java 中用于线程同步和协调的工具类,但它们之间存在一些区别。
-
功能不同:
-
CountDownLatch
是一种倒计时器,它允许一个或多个线程等待其他线程完成一定数量的任务后再继续执行。 -
CyclicBarrier
是一种屏障,它允许一组线程相互等待,直到所有线程都到达屏障点后再同时继续执行。
-
-
计数器的变化方式不同:
-
CountDownLatch
的计数器是一次性的,即初始值一旦设定,就不能被重置。每当一个线程完成一定数量的任务后,计数器的值就会减1,直到计数器的值为零。 -
CyclicBarrier
的计数器是循环的,即可以重复使用。每当一个线程到达屏障点后,计数器的值会减1,直到计数器的值为零,然后计数器会被重置为初始值,线程可以继续执行下一个周期。
-
-
等待方式不同:
-
CountDownLatch
使用await()
方法使线程进入等待状态,直到计数器的值为零。 -
CyclicBarrier
使用await()
方法使线程进入等待状态,直到所有线程都到达屏障点后才继续执行。
-
-
共同动作的执行方式不同:
-
CountDownLatch
没有提供共同动作的执行机制。 -
CyclicBarrier
可以通过构造函数指定一个共同动作,在所有线程到达屏障点后执行。
-
综上所述,CountDownLatch
适用于一个或多个线程等待其他线程完成一定数量的任务后再继续执行的场景,而 CyclicBarrier
适用于一组线程相互等待,直到所有线程都到达屏障点后再同时继续执行的场景。
当我们谈到 CountDownLatch
和 CyclicBarrier
的区别时,可以使用生活中的例子来说明:
-
CountDownLatch:
假设你是一支足球队的教练,你想要确保所有的球员都做好了准备才开始比赛。你会使用
CountDownLatch
来等待所有球员都准备好。你会在更衣室门口设置一个计数器(初始值为球员人数),当每个球员准备好时,他们会通知你,你会将计数器减1。只有当所有球员都准备好,即计数器的值为零时,你才会带领球队走出更衣室开始比赛。 -
CyclicBarrier:
假设你是一群朋友计划一起去登山,你们决定在某个固定的集合点集合后再一起出发。你会使用
CyclicBarrier
来确保大家都到达集合点后再开始行程。每个人在自己的家里准备好后,会前往集合点并等待其他人。当所有人都到达集合点后,就会触发屏障,大家会一起开始登山。
在这两个例子中,CountDownLatch
和 CyclicBarrier
都用于协调参与者的行动。CountDownLatch
用于等待一组线程完成某项任务后再继续执行,而 CyclicBarrier
用于等待一组线程到达一个屏障点后再同时继续执行。
以上是Java中常用的锁机制,每种锁机制都有其特点和适用场景,根据具体的业务场景选择适合的锁机制可以提高程序的性能和可靠性。
四、锁的使用技巧
锁的使用技巧和最佳实践是Java并发编程中非常重要的一部分。下面是一些有关锁的使用技巧和最佳实践:
- 锁的范围应该尽可能小
当使用锁来保护共享资源时,锁的范围应该尽可能小。这样可以减少锁的争用,提高程序的并发性能。如果锁的范围过大,会导致其他线程长时间等待锁的释放,从而影响程序的性能。
- 避免过度同步
过度同步是指在不必要的地方使用锁,导致程序的性能下降。为了避免过度同步,应该在确保线程安全性和可见性的前提下,尽可能地减少锁的使用。
- 使用局部变量
在使用锁来保护共享资源时,应该尽可能地使用局部变量。这样可以减少对共享资源的访问,提高程序的并发性能。
- 避免死锁
死锁是Java并发编程中一个非常严重的问题,必须避免发生。为了避免死锁,应该避免嵌套锁、避免长时间占用锁、按照相同的顺序获取锁等。
- 使用并发集合
Java提供了多种并发集合,例如ConcurrentHashMap、ConcurrentLinkedQueue等。这些并发集合可以在多线程环境下安全地访问和修改共享资源,从而减少了对锁的依赖。
- 使用可重入锁
可重入锁是一种特殊的锁,它可以被同一个线程多次获取。使用可重入锁可以避免死锁等问题,提高程序的可靠性。
- 使用读写锁
读写锁是一种特殊的锁,它可以分别对读操作和写操作进行加锁和解锁。使用读写锁可以提高程序的并发性能,因为多个线程可以同时读取共享资源。
总之,锁的使用技巧和最佳实践是Java并发编程中非常重要的一部分。了解如何使用锁来保护共享资源,防止竞争条件,并确保线程安全性和可见性,以及如何避免死锁和其他常见的多线程问题,并学习一些最佳实践,如避免过度同步、尽可能使用局部变量等,对于编写高效、可靠的并发程序非常有帮助。
五、Java内存模型
volatile,happens-before
Java内存模型(Java Memory Model,JMM)是Java虚拟机规范中描述的一种抽象计算机模型,用于定义多线程程序中共享内存区域的访问规则,确保多线程程序的正确性。
理解Java内存模型的原理和规则对于编写正确的并发代码至关重要。以下是一些相关的概念和技术:
5.1、主内存和工作内存
Java内存模型中定义了两个主要的内存区域:主内存和工作内存。所有的线程都可以访问主内存,但每个线程都有自己的工作内存。
在Java中,主内存(Main Memory)和工作内存(Working Memory)是两个重要的概念,用于描述多线程环境下的内存模型。
- 主内存(Main Memory):主内存是Java线程共享的内存区域,它是所有线程可以访问的公共存储区域。在主内存中存储了所有的共享变量,包括实例字段、静态字段和数组元素等。
- 工作内存(Working Memory):工作内存是线程独立的内存区域,每个线程都有自己的工作内存。工作内存是主内存的一部分的拷贝,用于存储线程执行的数据和操作。线程对变量的读写操作都是在工作内存中进行的。
线程与主内存之间通过工作内存进行交互,线程从主内存中读取变量的值到工作内存中进行操作,然后再将结果写回主内存。这样的交互是通过Java内存模型(Java Memory Model)规定的内存操作完成的。
需要注意的是,每个线程对于共享变量的操作都是在自己的工作内存中进行的,不同线程之间的工作内存是相互独立的,线程之间无法直接访问彼此的工作内存。因此,为了保证线程之间的可见性和一致性,需要通过内存屏障、锁、volatile等机制来进行同步和协调。
总结起来,主内存是线程共享的内存区域,存储了所有的共享变量;工作内存是线程独立的内存区域,用于存储线程执行的数据和操作。通过工作内存与主内存之间的交互,实现了多线程环境下的内存模型。
5.2、内存屏障
内存屏障是一种硬件或软件机制,用于强制处理器或编译器遵循指定的内存访问顺序。在Java内存模型中,内存屏障用于保证线程之间的正确同步。
内存屏障(Memory Barrier),也称为内存栅栏或内存栅障,是一种同步原语,用于控制对内存的访问和操作顺序,保证多线程环境下的内存可见性和有序性。
内存屏障分为两种类型:
- 读屏障(Read Barrier):读屏障用于确保在读操作之前,所有之前的读写操作都已经完成,以保证读取到最新的值。它会阻止对读操作的重排序,使得读操作必须在读屏障之后的指令之前执行。
- 写屏障(Write Barrier):写屏障用于确保在写操作之前,所有之前的读写操作都已经完成,以保证写入的值对其他线程可见。它会阻止对写操作的重排序,使得写操作必须在写屏障之前的指令之前执行。
内存屏障的作用是强制刷新处理器缓存或者阻止处理器将缓存写回主内存,以确保内存操作的顺序性和可见性。它们可以用于解决由于指令重排序、缓存一致性等因素导致的线程间数据不一致的问题。
在Java中,内存屏障的使用被封装在各种同步机制中,例如锁(如synchronized
、ReentrantLock
)、volatile
关键字、Atomic
类等。这些同步机制在适当的时候会插入内存屏障来保证内存操作的有序性和可见性。
需要注意的是,内存屏障的具体实现和效果可能因不同的硬件平台、编译器和JVM实现而有所不同。在编写多线程代码时,可以依赖于高级的同步机制,而无需直接使用内存屏障。
内存屏障可以分为以下几种类型:
- Load Barrier(读屏障):Load Barrier用于确保在该屏障之前的读操作完成之后,后续的读操作才能开始执行。它可以防止指令重排序,保证了读操作的顺序性和可见性。
- Store Barrier(写屏障):Store Barrier用于确保在该屏障之前的写操作完成之后,后续的写操作才能开始执行。它可以防止指令重排序,保证了写操作的顺序性和可见性。
- Read/Write Barrier(读写屏障):Read/Write Barrier是Load Barrier和Store Barrier的组合。它用于确保在该屏障之前的读写操作完成之后,后续的读写操作才能开始执行。它既保证了读操作的顺序性和可见性,也保证了写操作的顺序性和可见性。
- Full Barrier(全屏障):Full Barrier是最严格的屏障,它既包含了Read/Write Barrier的效果,又保证了Load Barrier和Store Barrier之间的顺序性和可见性。Full Barrier可以防止所有指令重排序,提供了最强的内存一致性。
这些屏障在不同的编程语言、硬件平台和编译器中可能有不同的名称和实现方式,但它们的基本作用是相似的:保证内存操作的顺序性和可见性,避免由于指令重排序等原因引发的数据不一致问题。
在Java中,内存屏障的使用被封装在各种同步机制中,例如锁、volatile
关键字、Atomic
类等。这些同步机制会根据需要自动插入适当类型的内存屏障来保证内存操作的有序性和可见性。
下面分别以代码示例的方式说明几种内存屏障的作用:
-
Load Barrier(读屏障):
int x = 0; int y = 0; volatile int z = 0; // 线程1 x = 1; y = z; // 读屏障,确保读操作在屏障之前的写操作完成 // 线程2 z = 2; // 写操作
在上述代码中,线程1中的读操作
y = z
之前插入了一个读屏障,确保线程1能够读取到最新的值。即使线程2对z
的写操作在y = z
之后执行,由于读屏障的存在,线程1仍然可以读取到更新后的值。 -
Store Barrier(写屏障):
volatile int x = 0; int y = 0; // 线程1 x = 1; // 写操作 y = 2; // 写操作 // 写屏障,确保写操作在屏障之前的写操作完成 // 线程2 int a = x; // 读操作 int b = y; // 读操作
在上述代码中,线程1在对变量
x
和y
进行写操作后,插入了一个写屏障。这个写屏障确保了线程1的写操作在屏障之前的写操作都已经完成。因此,当线程2进行读操作时,能够读取到线程1完成的最新的值。 -
Read/Write Barrier(读写屏障):
volatile int x = 0; volatile int y = 0; // 线程1 x = 1; // 写操作 // 读写屏障,确保写操作在屏障之前的写操作完成 int a = y; // 读操作 // 线程2 y = 2; // 写操作 // 读写屏障,确保写操作在屏障之前的写操作完成 int b = x; // 读操作
在上述代码中,线程1在写操作
x = 1
之后插入了一个读写屏障,确保线程1的写操作在屏障之前的写操作都已经完成。同样地,线程2在写操作y = 2
之后也插入了一个读写屏障。这样可以保证线程1和线程2在读操作时能够读取到对方完成的最新的值。 -
Full Barrier(全屏障):
volatile int x = 0; volatile int y = 0; // 线程1 x = 1; // 写操作 // 全屏障,确保写操作在屏障之前的写操作和读操作都已经完成 int a = y; // 读操作 // 线程2 y = 2; // 写操作 // 全屏障,确保写操作在屏障之前的写操作和读操作都已经完成 int b = x; // 读操作
在上述代码中,线程1和线程2都在写操作后插入了一个全屏障。全屏障确保了写操作在屏障之前的写操作和读操作都已经完成,从而保证线程1和线程2在读操作时能够读取到对方完成的最新的值。全屏障提供了最强的内存一致性。
需要注意的是,上述示例只是为了说明内存屏障的作用,并非Java代码中实际使用内存屏障的典型场景。实际应用中,内存屏障的使用由编译器、JVM和硬件平台来处理,通常被封装在同步机制中,如锁、volatile关键字和原子操作等。
5.3、happens-before关系
“happens-before”(发生在之前)是Java并发编程中的一个重要概念,它定义了对共享变量的读写操作之间的可见性和顺序性保证。
以下是关于”happens-before”原理的一些重要知识点:
- 程序顺序规则(Program Order Rule):在单个线程内,按照程序的顺序,前面的操作在后面的操作之前。即在同一个线程中,前面的操作的结果对后续操作可见。
- 监视器锁规则(Monitor Lock Rule):一个解锁操作(unlock)在同一个锁的后续锁定操作(lock)之前。即对于同一个锁对象,解锁操作的修改对于后续锁定操作的读取可见。
- volatile变量规则(Volatile Variable Rule):对于volatile变量的写操作在后续对该变量的读操作之前。即通过volatile变量的写入,可以保证对该变量的读取是可见的。
- 线程启动规则(Thread Start Rule):线程的启动操作在该线程的所有操作之前。即在一个线程启动之前,它的操作对于启动后的线程是可见的。
- 线程终止规则(Thread Termination Rule):线程的所有操作在终止操作之前。即一个线程的所有操作对于其他线程来说是可见的,直到该线程终止。
- 中断规则(Interruption Rule):对于被中断的线程,中断操作在后续对该线程的操作之前。即中断操作的修改对于后续操作的读取是可见的。
- 传递性(Transitivity):如果操作A在操作B之前,操作B在操作C之前,那么操作A在操作C之前。
“happens-before”原则提供了在多线程环境下对内存操作进行排序和可见性保证的规则。通过遵循”happens-before”原则,可以确保多线程程序中的操作按照预期顺序执行,并且线程间的数据共享是可靠的。在实际编程中,理解和正确应用”happens-before”原则是确保多线程程序正确性的关键。
5.4、volatile关键字
volatile
是Java中的关键字,用于声明变量,具有特殊的内存语义。它的主要原理是确保对被声明为 volatile
的变量的读取和写入操作具有可见性和有序性。
-
可见性:当一个线程对一个
volatile
变量进行写操作时,JVM会立即将该变量的最新值刷新到主内存中,而不是仅仅存储在线程的工作内存中。其他线程在读取该变量时,会从主内存中获取最新的值,而不是使用本地缓存的旧值。这样可以保证不同线程之间对于volatile
变量的读写操作是可见的,避免了使用过期的值。 -
有序性:
volatile
关键字还保证了变量的读写操作具有有序性。在一个线程中,所有的操作(包括volatile
变量的读写)都是按照程序的顺序执行的。这意味着在一个线程中,写入一个volatile
变量的操作发生在后续读取该变量的操作之前。这样可以确保在不同线程中对于volatile
变量的读写操作也具有一定的顺序性。
需要注意的是,volatile
关键字不能替代锁,它主要适用于以下情况:
- 对变量的写入操作不依赖于变量的当前值。
- 变量没有包含在具有原子性要求的复合操作中。
- 不需要保证变量的互斥访问。
当需要满足上述条件时,使用 volatile
可以提供一种轻量级的线程同步机制,但在其他情况下,仍然需要使用锁或其他更强大的同步机制来确保线程安全性。
5.5、synchronized关键字
synchronized是Java语言中的另一个关键字,用于实现线程之间的同步。使用synchronized关键字可以保证同一时间只有一个线程能够访问被保护的代码块。
具体可以参考文章开头介绍的内容
六、总结
目前就整理了这几种工作中常用的锁类型,也简单介绍了JMM内存模型,具体的每一个也都给出了示例,需要自己亲手体验下了。
了解以上这些概念和技术可以帮助您更好地理解Java内存模型,从而编写出更安全、更正确的并发程序。
后面会针对每一个小的知识点做一个更全面的分析。欢迎关注。
参考链接
https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
https://www.artima.com/insidejvm/ed2/threadsynchP.html
本文由mdnice多平台发布
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
源创会,线下重启!2023年7月1日深圳站—基础软件技术面面谈!免费票限时抢购! 2023年5月,由 Stackoverflow 发起的2023年度开发者调查数据显示,PostgreSQL 已经超越 MySQL 位居第一,成为开发人员首选。PostgreSQL…