Java内存模型详解
Java内存模型基础概念
Java内存模型(Java Memory Model,JMM)是Java虚拟机规范中定义的一种抽象的内存模型,用于屏蔽不同操作系统和硬件平台对内存访问的差异,以实现Java程序在各种平台下都能达到一致的内存访问效果。
在JMM中,将内存分为主内存(Main Memory)和工作内存(Working Memory)。主内存是所有线程共享的内存区域,存储了共享变量(类的成员变量、静态变量等)。而每个线程都有自己独立的工作内存,工作内存中保存了该线程使用到的共享变量的副本。
共享变量与线程工作内存
例如,我们有如下代码:
public class SharedVariableExample {
private static int sharedValue = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
sharedValue = 1;
System.out.println("Thread1 set sharedValue to " + sharedValue);
});
Thread thread2 = new Thread(() -> {
int localValue = sharedValue;
System.out.println("Thread2 read sharedValue as " + localValue);
});
thread1.start();
thread2.start();
}
}
在这段代码中,sharedValue
是共享变量,存储在主内存中。thread1
和thread2
各自有自己的工作内存,当thread1
修改sharedValue
时,首先在自己的工作内存中修改副本,然后再将修改后的值刷新到主内存。而thread2
读取sharedValue
时,先从主内存读取到自己的工作内存副本中,再进行使用。
内存交互操作
JMM定义了一系列的内存交互操作来管理主内存和工作内存之间的数据传输,主要包括以下几种:
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
Java内存模型中的可见性问题
可见性概念
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在JMM中,由于线程工作内存和主内存的存在,可见性问题经常会出现。
例如下面的代码:
public class VisibilityExample {
private static boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (!flag) {
// 线程1在等待flag变为true
}
System.out.println("Thread1 exited loop");
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("Thread2 set flag to true");
});
thread1.start();
thread2.start();
}
}
在上述代码中,thread2
修改了flag
的值,但thread1
可能会一直处于循环中,无法感知到flag
的变化。这是因为thread1
工作内存中的flag
副本没有及时更新,尽管主内存中的flag
已经被thread2
修改。
导致可见性问题的原因
- 缓存一致性问题:现代CPU为了提高性能,每个CPU核心都有自己的高速缓存。当线程在CPU核心上运行时,对共享变量的操作首先在高速缓存中进行。不同核心的高速缓存之间可能存在不一致的情况,导致一个核心上对共享变量的修改不能及时被其他核心感知。
- 指令重排序:为了提高程序的执行效率,编译器和处理器会对指令进行重排序。在单线程环境下,指令重排序不会影响程序的最终执行结果。但在多线程环境下,指令重排序可能会导致共享变量的修改在其他线程中不可见。
解决可见性问题的方法
使用volatile关键字
volatile
关键字可以保证变量的可见性。当一个变量被声明为volatile
时,对这个变量的读操作会从主内存中读取最新的值,写操作会立即把值刷新到主内存中。
修改上述VisibilityExample
代码如下:
public class VolatileVisibilityExample {
private static volatile boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (!flag) {
// 线程1在等待flag变为true
}
System.out.println("Thread1 exited loop");
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("Thread2 set flag to true");
});
thread1.start();
thread2.start();
}
}
在这段代码中,flag
被声明为volatile
,thread2
修改flag
后,thread1
能够及时感知到这个变化,从而退出循环。
使用synchronized关键字
synchronized
关键字不仅可以保证线程安全,还可以保证可见性。当一个线程进入synchronized
块时,会先从主内存中读取共享变量的值,退出synchronized
块时,会把共享变量的值刷新到主内存中。
例如:
public class SynchronizedVisibilityExample {
private static int value = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (SynchronizedVisibilityExample.class) {
value = 1;
System.out.println("Thread1 set value to " + value);
}
});
Thread thread2 = new Thread(() -> {
synchronized (SynchronizedVisibilityExample.class) {
int localValue = value;
System.out.println("Thread2 read value as " + localValue);
}
});
thread1.start();
thread2.start();
}
}
在上述代码中,thread1
和thread2
通过synchronized
同步块,保证了对value
变量的读写操作都从主内存进行,从而保证了可见性。
Java内存模型中的原子性问题
原子性概念
原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中,对于一些基本数据类型的简单操作(如赋值、读取),JVM通常会保证其原子性。但对于复合操作(如i++
,它包含读取、加1、赋值三个操作),则不具备原子性。
例如:
public class AtomicityExample {
private static int count = 0;
public static void main(String[] args) {
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++;
}
});
threads[i].start();
}
for (Thread thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Final count value: " + count);
}
}
在上述代码中,预期的结果是count
的值为100000,但实际运行结果往往小于这个值。这是因为count++
操作不是原子性的,多个线程同时执行count++
时,可能会出现数据竞争问题。
导致原子性问题的原因
在多线程环境下,当多个线程同时对共享变量进行操作时,如果这些操作不是原子性的,就会出现数据竞争。例如,一个线程读取了共享变量的值,还没来得及修改并写回主内存,另一个线程也读取了这个值,这样就会导致最终结果不符合预期。
解决原子性问题的方法
使用synchronized关键字
synchronized
关键字可以保证同一时刻只有一个线程能够进入同步块,从而保证了同步块内代码的原子性。
修改上述AtomicityExample
代码如下:
public class SynchronizedAtomicityExample {
private static int count = 0;
public static void main(String[] args) {
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() -> {
synchronized (SynchronizedAtomicityExample.class) {
for (int j = 0; j < 1000; j++) {
count++;
}
}
});
threads[i].start();
}
for (Thread thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Final count value: " + count);
}
}
在这段代码中,通过synchronized
同步块,保证了count++
操作的原子性,最终count
的值为100000。
使用java.util.concurrent.atomic包下的原子类
Java提供了java.util.concurrent.atomic
包,其中包含了一系列原子类,如AtomicInteger
、AtomicLong
等。这些原子类使用了底层的CAS(Compare - And - Swap)操作来保证原子性。
例如:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicClassAtomicityExample {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) {
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count.incrementAndGet();
}
});
threads[i].start();
}
for (Thread thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Final count value: " + count.get());
}
}
在上述代码中,使用AtomicInteger
的incrementAndGet
方法保证了自增操作的原子性,最终count
的值为100000。
Java内存模型中的有序性问题
有序性概念
有序性是指程序执行的顺序按照代码的先后顺序执行。然而,在实际执行中,为了提高性能,编译器和处理器可能会对指令进行重排序。在单线程环境下,重排序不会影响程序的正确性,但在多线程环境下,重排序可能会导致程序出现错误。
例如:
public class ReorderingExample {
private static int a = 0;
private static int b = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
a = 1;
b = 2;
});
Thread thread2 = new Thread(() -> {
if (b == 2) {
System.out.println("a is " + a);
}
});
thread1.start();
thread2.start();
}
}
在上述代码中,理论上如果thread2
在thread1
执行完后执行,并且b == 2
,那么a
应该为1。但由于指令重排序,thread1
中a = 1
和b = 2
的执行顺序可能被改变,导致thread2
在b = 2
执行后,a = 1
还未执行,从而输出a is 0
。
导致有序性问题的原因
- 编译器优化:编译器为了提高代码的执行效率,会对代码进行优化,其中包括指令重排序。编译器会根据一定的规则对指令进行重新排列,以减少CPU的等待时间。
- 处理器优化:现代处理器采用了流水线技术、乱序执行等优化手段来提高性能。这些优化可能会导致指令在处理器中实际执行的顺序与程序代码中的顺序不一致。
解决有序性问题的方法
使用volatile关键字
volatile
关键字除了保证可见性,还能禁止指令重排序。当一个变量被声明为volatile
时,编译器和处理器会遵循一定的规则,不会对volatile
变量相关的指令进行重排序。
例如:
public class VolatileOrderingExample {
private static volatile int a = 0;
private static int b = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
a = 1;
b = 2;
});
Thread thread2 = new Thread(() -> {
if (b == 2) {
System.out.println("a is " + a);
}
});
thread1.start();
thread2.start();
}
}
在这段代码中,由于a
被声明为volatile
,a = 1
和b = 2
的执行顺序不会被重排序,从而保证了thread2
在b == 2
时,a
一定为1。
使用synchronized关键字
synchronized
关键字也可以保证有序性。当一个线程进入synchronized
块时,会先从主内存中读取共享变量的值,退出synchronized
块时,会把共享变量的值刷新到主内存中。同时,在synchronized
块内的指令不会被重排序到块外,块外的指令也不会被重排序到块内。
例如:
public class SynchronizedOrderingExample {
private static int a = 0;
private static int b = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (SynchronizedOrderingExample.class) {
a = 1;
b = 2;
}
});
Thread thread2 = new Thread(() -> {
synchronized (SynchronizedOrderingExample.class) {
if (b == 2) {
System.out.println("a is " + a);
}
}
});
thread1.start();
thread2.start();
}
}
在上述代码中,通过synchronized
同步块,保证了thread1
和thread2
对a
和b
的操作顺序,从而避免了指令重排序带来的问题。
Java内存模型与多线程并发编程实践
在多线程并发编程中,深入理解Java内存模型是非常重要的。我们需要根据具体的业务需求,合理地使用volatile
、synchronized
等关键字以及原子类来保证程序的正确性和性能。
例如,在实现一个简单的计数器时,如果只需要保证可见性,可以使用volatile
关键字:
public class VolatileCounter {
private static volatile int counter = 0;
public static void increment() {
counter++;
}
public static int getCounter() {
return counter;
}
}
但如果需要保证原子性,就应该使用AtomicInteger
:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private static AtomicInteger counter = new AtomicInteger(0);
public static void increment() {
counter.incrementAndGet();
}
public static int getCounter() {
return counter.get();
}
}
而对于一些复杂的业务逻辑,可能需要使用synchronized
关键字来保证线程安全、可见性和有序性。
public class SynchronizedBusinessLogic {
private static int data = 0;
public static synchronized void updateData(int value) {
// 复杂的业务逻辑,可能涉及多个操作
data = value;
// 其他操作
}
public static synchronized int getData() {
return data;
}
}
在实际应用中,还需要考虑性能问题。例如,synchronized
关键字虽然能保证线程安全,但由于它是独占锁,可能会导致性能瓶颈。在这种情况下,可以考虑使用并发包中的其他同步工具,如ReentrantLock
、Semaphore
等,它们提供了更灵活的同步控制方式,同时在一定程度上可以提高性能。
总之,在多线程并发编程中,我们需要综合考虑可见性、原子性和有序性,结合Java内存模型的特性,选择合适的同步机制来编写高效、正确的多线程程序。