Java的内存模型(JMM)和锁机制
Java内存模型(Java Memory Model, JMM)是Java虚拟机(JVM)规范中定义的一种内存模型(抽象概念),它描述了Java程序中各种变量(包括实例字段、静态字段和构成数组对象的元素)的访问规则,以及在多线程环境下这些变量如何被线程共享和同步(变量的共享和同步机制)。
JMM的主要目标是//定义线程如何以及何时可以看到由其他线程修改过的共享变量的值,以及在必须时进行同步的机制和方式//,//既定义线程如何与主内存进行交互,以及如何保证原子性、可见性和有序性//。它帮助程序员编写出在多线程环境下能够正确运行的Java程序。
Java内存模型(JMM)
Java内存模型定义了线程和主内存之间的抽象关系,以及线程之间如何共享变量。它主要解决了多线程环境下,变量可见性和有序性的问题。
- 变量可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在Java中,这通常通过
volatile
关键字或synchronized
块来保证。- 有序性:JMM允许编译器和处理器对指令进行重排序以优化性能,但这可能会导致多线程程序出现意外的行为。通过
volatile
关键字或synchronized
块可以禁止这种重排序。
JMM规定了所有变量都存储在主内存中,每个线程还有自己的工作内存(线程栈的一部分),线程对变量的所有操作都必须在工作内存中完成,然后再刷新到主内存。
JMM定义的关键概念:
- 主内存:Java堆内存中的方法区以及所有Java线程共享的内存区域。所有变量都存储在主内存中,包括实例变量、静态变量和数组元素,。
- 工作内存:每个线程都有自己的工作内存(也称为本地内存),它是线程私有的,包含了该线程对共享变量的私有副本以及线程执行所需的其他信息(如指令、常量等)。线程对共享变量的所有操作(读取、赋值等)都必须在工作内存中进行(不能直接读写主内存中的变量),然后再刷新回主内存。
- 可见性:一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM通过规定线程在何时必须将工作内存中的变量值刷新回主内存,以及何时必须从主内存中重新读取共享变量的值,来实现可见性。(大概就是:不同线程之间无法直接访问对方工作内存中的变量,线程间的变量值的传递均需要通过主内存来完成,当一个线程修改了共享变量的值,其他线程能够立即得知这个修改)
- 原子性:一个或多个操作在执行过程中不会被其他线程中断。Java内存模型只保证基本数据类型的读取和赋值是原子的,对于复合操作(如i++),则需要通过同步机制来保证原子性。(大概就是:一个操作要么全部完成,要么完全不发生,不会被线程调度机制中断)
- 有序性:程序执行的顺序按照代码的先后顺序执行。但是,在并发环境下,由于指令重排序(Instruction Reorder)的存在,程序的执行顺序可能与代码顺序不一致。Java内存模型允许编译器和处理器对指令进行重排序,以提高程序执行的性能。但是,重排序过程不会违反as-if-serial语义,即程序执行的结果应该与在单线程中按代码顺序执行的结果一致。(大概就是:程序执行的顺序按照代码的先后顺序执行,但在多线程环境中,由于指令重排序等优化手段,实际的执行顺序可能与代码顺序不一致)
Java提供的同步机制
为了保证多线程程序的正确性,Java提供了多种同步机制,如synchronized关键字、volatile关键字、Lock接口等,这些机制可以帮助程序员控制线程之间的内存可见性和操作的有序性。
主要的同步机制:
synchronized关键字:
这是Java最基本的同步机制。它可以用来修饰方法或代码块,确保在同一时刻只有一个线程能够执行该方法或代码块。它通过锁机制(通常是对象锁或类锁)来确保同一时刻只有一个线程能执行某个方法或代码块。
修饰方法时,它会自动锁定调用该方法的对象或该类(如果是静态方法)。
修饰代码块时,需要指定一个锁对象,多个线程需要对该锁对象进行竞争,以获得执行权限。
使用synchronized
关键字的示例
public class Counter {
private int count = 0;
// 使用synchronized修饰方法,确保线程安全
public synchronized void increment() {
count++; // 这是一个非原子操作,但在synchronized方法中它是安全的
}
public synchronized int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
// 创建两个线程来更新计数器
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
// 启动线程
t1.start();
t2.start();
// 等待线程完成
t1.join();
t2.join();
// 打印最终计数器值
System.out.println("Final count: " + counter.getCount()); // 应该是20000
}
}
volatile关键字:
用于确保变量的可见性。当一个变量被volatile修饰后,它会告诉JVM该变量的值可能会被其他线程修改,因此在每次读取该变量时都需要重新从主内存中读取,而不是从线程的工作内存中读取。
注意,volatile不能确保操作的原子性,它仅适用于那些不需要同步代码块就能保证原子性的操作。
使用volatile
关键字的示例
public class VolatileExample {
// 使用volatile修饰共享变量,确保所有线程都能看到最新的值
private volatile boolean running = true;
// 启动一个线程来模拟运行中的任务
public void startTask() {
Thread taskThread = new Thread(() -> {
while (running) {
// 模拟任务执行,这里只是简单地打印信息
System.out.println("Task is running...");
try {
// 为了演示,让线程暂停一段时间
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 保持中断状态
return;
}
}
System.out.println("Task has stopped.");
});
// 启动线程
taskThread.start();
// 假设在一段时间后,我们想要停止任务
try {
Thread.sleep(5000); // 等待5秒来模拟任务运行时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
// 更新volatile变量以停止任务
running = false;
}
public static void main(String[] args) {
VolatileExample example = new VolatileExample();
example.startTask();
// 注意:在实际应用中,你可能需要等待taskThread真正结束,这里为了简化示例而省略了
}
}
在这个示例中,
running
变量被volatile
修饰,这意味着当running
的值被修改时,所有线程都会看到最新的值。在startTask
方法中,我们启动了一个线程来模拟一个长时间运行的任务,该任务通过检查running
变量的值来决定是否继续执行。在主线程中,我们通过将running
设置为false
来停止任务。由于running
是volatile
的,因此当它被更新时,运行任务的线程能够立即看到这一变化,并相应地停止执行。
wait()和notify()/notifyAll()方法:
这三个方法都是Object类中的方法,用于线程间的通信。
wait()方法会使当前线程等待,直到其他线程调用该对象的notify()或notifyAll()方法。调用wait()方法的线程必须持有该对象的锁,调用后该线程会释放锁并进入等待状态。
notify()方法会唤醒等待该对象锁的单个线程(如果有的话),而notifyAll()会唤醒所有等待该对象锁的线程。
使用wait()和notify()/notifyAll()方法的示例
public class WaitNotifyExample {
private final Object lock = new Object(); // 锁对象
private boolean ready = false; // 条件变量
// 生产者线程调用此方法
public void produce() throws InterruptedException {
synchronized (lock) {
// 假设生产者需要做一些准备工作
System.out.println("Producer is preparing...");
Thread.sleep(1000); // 模拟耗时操作
// 准备工作完成,设置条件变量并通知等待的消费者
ready = true;
lock.notify(); // 唤醒等待在该锁上的一个线程
System.out.println("Product is ready. Notified consumer.");
}
}
// 消费者线程调用此方法
public void consume() throws InterruptedException {
synchronized (lock) {
// 等待产品准备好
while (!ready) {
lock.wait(); // 释放锁并进入等待状态
}
// 产品已准备好,开始消费
System.out.println("Consumer is consuming the product.");
Thread.sleep(1000); // 模拟消费过程
// 消费完成,重置条件变量供下一轮使用
ready = false;
}
}
public static void main(String[] args) {
WaitNotifyExample example = new WaitNotifyExample();
// 创建生产者线程
Thread producer = new Thread(() -> {
try {
example.produce();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// 创建消费者线程
Thread consumer = new Thread(() -> {
try {
example.consume();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
// 启动线程
producer.start();
consumer.start();
// 注意:在这个简单的示例中,没有等待消费者线程完成。
// 在实际应用中,你可能需要某种形式的同步来确保生产者和消费者之间的正确交互。
}
}
请注意,在这个示例中,
wait()
方法被放在了一个while
循环中。这是因为wait()
方法可能会由于“虚假唤醒”(spurious wakeup)而被唤醒,即使没有线程调用notify()
或notifyAll()
。因此,将wait()
放在while
循环中并使用条件变量来检查实际状态是一种常见且推荐的做法。此外,还需要注意,
wait()
,notify()
, 和notifyAll()
方法必须在同步代码块或同步方法中调用,因为它们依赖于对象锁。在这个示例中,我们使用了一个单独的锁对象lock
来控制对共享资源(这里是ready
变量)的访问和线程间的通信。
Lock接口:
Java 5.0引入了java.util.concurrent.locks包,其中定义了Lock接口。Lock接口提供了比synchronized关键字更灵活的锁定机制。
Lock接口的实现类(如ReentrantLock,它支持可重入的互斥锁,还支持尝试非阻塞地获取锁、可中断地获取锁、定时尝试获取锁等功能)提供了更加丰富的功能,如尝试非阻塞地获取锁、可中断地获取锁、定时尝试获取锁等。
使用ReentrantLock
的示例
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class CounterWithLock {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 获取锁
try {
count++;
} finally {
lock.unlock(); // 释放锁,无论是否发生异常
}
}
public int getCount() {
lock.lock(); // 这里也可以考虑使用tryLock()或读写锁等更细粒度的控制
try {
return count;
} finally {
lock.unlock();
}
}
// main方法和上面的示例类似,只是Counter类被替换为了CounterWithLock类
}
Condition接口:
Condition接口是与Lock接口配合使用的,是Lock接口提供的一个条件对象,用于线程间的通信,它提供了更灵活的线程间通信方式。
每个Lock对象都可以创建多个Condition实例,用于实现更复杂的线程间通信。
使用Condition接口示例
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionExample {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private boolean ready = false;
// 线程执行的任务
public void task() throws InterruptedException {
lock.lock(); // 获取锁
try {
// 等待条件满足
while (!ready) {
condition.await(); // 释放锁并进入等待状态
}
// 条件满足,执行任务
System.out.println("Condition met, task is executing.");
// 假设任务完成后需要重置条件
ready = false;
} finally {
lock.unlock(); // 无论如何,最后都要释放锁
}
}
// 触发条件的方法
public void triggerCondition() {
lock.lock(); // 获取锁
try {
ready = true; // 设置条件为true
condition.signalAll(); // 唤醒所有等待该条件的线程
} finally {
lock.unlock(); // 释放锁
}
}
public static void main(String[] args) {
ConditionExample example = new ConditionExample();
// 创建并启动线程
Thread thread = new Thread(() -> {
try {
example.task();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
thread.start();
// 主线程等待一会儿,然后触发条件
try {
Thread.sleep(1000); // 等待一秒,模拟其他操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
example.triggerCondition(); // 触发条件,唤醒等待的线程
}
}
在这个示例中,我们定义了一个
ConditionExample
类,它包含一个ReentrantLock
锁和一个Condition
条件对象。task()
方法模拟了一个线程需要等待某个条件满足才能执行的任务,而triggerCondition()
方法则用于在满足某个条件时触发该条件,从而唤醒等待的线程。注意,在调用
condition.await()
时,线程会释放锁并进入等待状态,直到其他线程调用condition.signal()
或condition.signalAll()
来唤醒它。同样地,在调用这些方法之前,必须持有与Condition
对象相关联的锁。这是通过调用lock.lock()
来完成的,并且在方法结束时通过lock.unlock()
来释放锁,以确保锁的释放总是会发生,无论方法是否成功执行。
Semaphore(信号量):
Semaphore是一个计数信号量,是一种基于计数的同步机制,可以用来控制同时访问某个特定资源的线程数量。它不是直接通过某个接口或类的形式实现的,而是java.util.concurrent包中的一个类。
它允许多个线程同时访问某个资源,但会限制同时访问的线程数。
使用Semaphore(信号量)的示例
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
// 创建一个Semaphore对象,设置许可数量为2
// 这意味着同时最多只能有两个线程可以访问资源
private final Semaphore semaphore = new Semaphore(2);
// 模拟的资源访问方法
public void accessResource() {
try {
// 请求许可
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + " has acquired a permit and is accessing the resource.");
// 模拟资源访问的耗时操作
Thread.sleep(1000);
// 访问完成后,释放许可
semaphore.release();
System.out.println(Thread.currentThread().getName() + " has released the permit.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 保持中断状态
System.out.println(Thread.currentThread().getName() + " was interrupted while accessing the resource.");
}
}
public static void main(String[] args) {
SemaphoreExample example = new SemaphoreExample();
// 创建并启动多个线程来访问资源
for (int i = 0; i < 5; i++) {
new Thread(() -> {
example.accessResource();
}, "Thread-" + (i + 1)).start();
}
}
}
在这个示例中,我们创建了一个
Semaphore
对象,其初始许可数量为2。然后,我们定义了一个accessResource
方法来模拟对资源的访问。在访问资源之前,线程会尝试通过调用semaphore.acquire()
来获取许可。由于许可数量被限制为2,因此同时最多只有两个线程能够进入accessResource
方法的主体部分。一旦线程完成了对资源的访问,它就会通过调用semaphore.release()
来释放许可,从而允许其他等待的线程获取许可并访问资源。
运行这个程序,你会看到类似以下的输出(具体顺序可能会有所不同,因为线程的执行是并发的):
Thread-1 has acquired a permit and is accessing the resource.
Thread-2 has acquired a permit and is accessing the resource.
Thread-1 has released the permit.
Thread-3 has acquired a permit and is accessing the resource.
Thread-2 has released the permit.
Thread-4 has acquired a permit and is accessing the resource.
Thread-3 has released the permit.
Thread-5 has acquired a permit and is accessing the resource.
Thread-4 has released the permit. Thread-5 has released the permit.
CountDownLatch(倒计时锁存器):
CountDownLatch是一种同步辅助类,它允许一个或多个线程等待直到在其他线程中执行的一组操作完成。它也不是通过接口或继承关系实现的,而是直接作为一个类存在。
它通常用于等待直到一组异步操作完成。
使用CountDownLatch(倒计时锁存器)的示例
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
// 创建一个CountDownLatch实例,设置计数器的初始值为2
// 这意味着我们需要等待两个任务完成
CountDownLatch latch = new CountDownLatch(2);
// 创建一个线程池来执行任务
ExecutorService executor = Executors.newFixedThreadPool(2);
// 提交第一个任务
executor.submit(() -> {
try {
// 模拟任务执行
System.out.println("Task 1 is running");
Thread.sleep(1000); // 假设任务需要1秒来完成
System.out.println("Task 1 is done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 任务完成后,计数器的值减1
latch.countDown();
}
});
// 提交第二个任务
executor.submit(() -> {
try {
// 模拟任务执行
System.out.println("Task 2 is running");
Thread.sleep(2000); // 假设这个任务需要2秒来完成
System.out.println("Task 2 is done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 任务完成后,计数器的值减1
latch.countDown();
}
});
// 等待直到所有任务完成
latch.await(); // 这会阻塞当前线程,直到计数器的值变为0
// 关闭线程池
executor.shutdown();
// 所有任务都已完成,继续执行后续操作
System.out.println("All tasks are completed.");
}
}
在这个示例中,我们创建了一个
CountDownLatch
实例,其计数器的初始值为2。然后,我们提交了两个任务到线程池中执行。每个任务在执行完成后都会调用latch.countDown()
来将计数器的值减1。主线程通过调用latch.await()
来等待,直到计数器的值变为0,这表示所有任务都已经完成。然后,主线程可以继续执行后续操作。
CyclicBarrier(循环屏障):
CyclicBarrier是一种同步辅助类,它用于让一组线程相互等待,直到到达某个公共屏障点。类似于CountDownLatch,CyclicBarrier也是直接作为一个类存在,而不是通过接口或继承关系实现的。
在所有线程都到达屏障点之前,它们将在屏障点处阻塞。当最后一个线程到达屏障点时,屏障会打开,此时所有线程都将被释放并继续执行。
使用CyclicBarrier(循环屏障)的示例
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierExample {
// 创建一个CyclicBarrier对象,设置屏障点需要等待的线程数量为3
// 第二个参数是一个Runnable对象,它是当所有线程都到达屏障点时会执行的任务(可选)
private final CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("All threads have reached the barrier. Barrier is now broken.");
});
public void task() {
// 模拟任务执行前的准备工作
try {
// 假设每个线程都需要一些时间来准备
Thread.sleep((long) (Math.random() * 1000));
System.out.println(Thread.currentThread().getName() + " is ready.");
// 等待直到所有线程都到达屏障点
barrier.await();
// 所有线程都到达屏障点后,继续执行后续任务
System.out.println(Thread.currentThread().getName() + " is continuing after the barrier.");
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
CyclicBarrierExample example = new CyclicBarrierExample();
// 创建并启动三个线程来执行任务
for (int i = 1; i <= 3; i++) {
new Thread(() -> {
example.task();
}, "Thread-" + i).start();
}
}
}
在这个示例中,我们创建了一个
CyclicBarrier
对象,其参数为3,表示需要等待3个线程都到达屏障点。我们还提供了一个Runnable
对象作为可选参数,它将在所有线程都到达屏障点时执行。然后,我们定义了一个
task()
方法,该方法模拟了线程在到达屏障点之前的准备工作,并调用了barrier.await()
来等待其他线程。一旦所有线程都调用了await()
方法并成功到达屏障点,屏障就会被打开,所有线程都会继续执行await()
之后的代码。在
main()
方法中,我们创建了三个线程来执行task()
方法,并启动了它们。你会注意到,每个线程都会打印一条消息表示它已准备好,然后等待其他线程。一旦所有线程都到达屏障点,它们会一起继续执行后续任务,并打印相应的消息。这个示例展示了
CyclicBarrier
如何用于同步一组线程的执行,直到它们都到达某个公共点。
锁机制
锁机制是计算机编程中一种用于控制对共享资源访问的重要同步机制。在并发编程中,多个线程或进程可能会同时尝试访问同一资源,这就有可能导致数据不一致、竞态条件等问题。锁机制通过确保在同一时间只有一个线程或进程能够访问某个资源,从而避免了这些问题。Java中主要有两种锁机制:
内置锁(Synchronized Locks):
通过synchronized
关键字实现,可以修饰方法或代码块。当一个线程访问某个对象的synchronized
方法或代码块时,它会先尝试获取该对象的锁;如果锁已被其他线程持有,则该线程会阻塞,直到锁被释放。
显式锁(Explicit Locks):
从Java 1.5开始,引入了java.util.concurrent.locks
包,提供了比synchronized
更灵活的锁机制,如ReentrantLock
。显式锁允许更复杂的同步控制,如尝试非阻塞地获取锁、可中断地获取锁以及尝试获取锁时设置超时等。
Java中提供了多种锁机制,例如:
synchronized关键字:Java内置的同步机制,可以修饰方法或代码块。当一个线程访问某个对象的synchronized方法或代码块时,它会获得该对象的锁,其他线程必须等待锁被释放后才能访问。
ReentrantLock:这是java.util.concurrent.locks包下的一个类,提供了比synchronized更灵活的锁定操作。它支持显式地锁定和解锁操作,也支持尝试锁定、定时锁定和可中断的锁定。
ReadWriteLock:读写锁,它允许多个读线程同时访问资源,但在写线程访问资源时,会排斥其他所有读线程和写线程。这对于读操作远多于写操作的场景非常有用,可以显著提高性能。
StampedLock:Java 8中引入的一种锁,它提供了对读写锁的更优支持,以及一种乐观读锁的模式。与ReadWriteLock相比,StampedLock在某些情况下可以提供更高的吞吐量。
锁机制的选择取决于具体的应用场景和性能要求。例如,在需要简单且自动管理的锁时,可以使用synchronized;在需要更灵活的控制时,可以使用ReentrantLock;在读多写少的场景下,ReadWriteLock或StampedLock可能是更好的选择。
总的来说,锁机制是并发编程中不可或缺的一部分,它们帮助程序员管理对共享资源的访问,从而避免了并发编程中常见的问题。
JMM和锁机制的关系
JMM和锁机制在Java并发编程中相互协作,共同确保多线程程序的正确性和高效性。JMM定义了线程间如何共享和访问变量,而锁机制则提供了一种同步机制,确保在并发环境下对共享资源的访问是安全的。
- 变量可见性:通过锁机制(如
synchronized
块或volatile
变量)可以确保一个线程对共享变量的修改能够被其他线程看到。 - 互斥访问:锁机制通过互斥地访问共享资源来防止数据竞争和不一致性的发生。
总的来说,Java内存模型和锁机制是Java并发编程的基石,理解和掌握它们对于编写高效、可维护的并发程序至关重要。