Java内存模型与线程安全
2021-12-273.5k 阅读
Java内存模型
什么是Java内存模型
Java内存模型(Java Memory Model,JMM)是一种抽象的概念,它定义了Java程序中多线程之间如何访问共享变量的规则。JMM的主要目标是确保在多线程环境下,程序的行为是可预测的,并且能够正确地处理内存可见性和线程安全问题。
在Java中,所有的共享变量都存储在主内存(Main Memory)中。每个线程都有自己的工作内存(Working Memory),工作内存中保存了该线程使用到的共享变量的副本。当线程访问共享变量时,它首先从主内存中将变量的值复制到自己的工作内存中,然后在工作内存中进行操作。当线程修改了共享变量的值后,并不会立即将其写回到主内存中,而是在某个时刻将修改后的值刷新回主内存。
Java内存模型的特性
- 原子性(Atomicity)
- 原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中,对于基本数据类型(除了 long 和 double)的变量赋值操作,以及使用
synchronized
关键字修饰的代码块,都是具有原子性的。 - 例如,以下代码中
count++
操作不是原子性的:
- 原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中,对于基本数据类型(除了 long 和 double)的变量赋值操作,以及使用
public class AtomicityTest {
private static int count = 0;
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; 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: " + count);
}
}
- 上述代码创建了10个线程,每个线程对
count
变量进行1000次自增操作。理想情况下,最终count
的值应该是10000,但由于count++
不是原子操作,实际结果往往小于10000。这是因为count++
操作可以分解为读取count
的值、增加1、再写回count
的值这三个步骤,在多线程环境下可能会出现数据竞争。 - 如果要保证
count++
操作的原子性,可以使用AtomicInteger
类,它提供了原子性的自增操作:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicityWithAtomicInteger {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; 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: " + count.get());
}
}
- 可见性(Visibility)
- 可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在Java中,由于线程工作内存和主内存的存在,共享变量的修改在没有特殊处理的情况下,其他线程可能不会立即看到。
- 例如,以下代码中
flag
变量的修改可能不会被其他线程立即看到:
public class VisibilityTest {
private static boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("Thread1 set flag to true");
});
Thread thread2 = new Thread(() -> {
while (!flag) {
// Do nothing, just wait for flag to be true
}
System.out.println("Thread2 sees flag is true");
});
thread1.start();
thread2.start();
}
}
- 在上述代码中,
thread1
线程将flag
设置为true
,但thread2
线程可能会一直处于循环等待状态,因为flag
的修改没有及时刷新到主内存,thread2
线程的工作内存中的flag
副本还是false
。 - 要解决可见性问题,可以使用
volatile
关键字修饰共享变量。volatile
关键字可以保证变量的修改对其他线程是立即可见的:
public class VisibilityWithVolatile {
private static volatile boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("Thread1 set flag to true");
});
Thread thread2 = new Thread(() -> {
while (!flag) {
// Do nothing, just wait for flag to be true
}
System.out.println("Thread2 sees flag is true");
});
thread1.start();
thread2.start();
}
}
- 有序性(Ordering)
- 有序性是指程序执行的顺序按照代码的先后顺序执行。然而,在Java中,为了提高程序的执行效率,编译器和处理器可能会对指令进行重排序。重排序可能会导致程序在多线程环境下出现意外的结果。
- 例如,以下代码可能会因为指令重排序而出现问题:
public class OrderingTest {
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
线程进入if
语句时,a
应该已经被thread1
线程设置为1。但由于指令重排序,thread1
线程可能先执行b = 2
,然后再执行a = 1
。这样当thread2
线程进入if
语句时,b
为2,但a
可能还没有被设置为1,从而输出a is 0
。 volatile
关键字除了保证可见性,还能禁止指令重排序。另外,synchronized
关键字也能保证有序性,因为synchronized
块中的代码在同一时刻只能被一个线程执行,从而避免了指令重排序带来的问题。
线程安全
线程安全的定义
线程安全是指当多个线程访问某个类、对象或者方法时,这个类、对象或者方法能够表现出正确的行为,不会因为多线程的并发访问而导致数据不一致或者程序出现错误。一个线程安全的类或者方法应该能够保证在多线程环境下,对其共享状态的访问和修改是正确的。
线程安全的实现方式
- 不可变对象(Immutable Objects)
- 不可变对象是指一旦创建,其状态就不能被修改的对象。在Java中,
String
类就是一个典型的不可变对象。不可变对象天生就是线程安全的,因为多个线程无法修改其状态,也就不存在数据竞争的问题。 - 例如,以下自定义一个不可变对象
Point
:
- 不可变对象是指一旦创建,其状态就不能被修改的对象。在Java中,
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
}
- 在上述代码中,
Point
类被声明为final
,防止被继承。x
和y
字段被声明为final
,保证一旦初始化后就不能被修改。这样,多个线程可以安全地共享Point
对象。
- 互斥同步(Mutex Synchronization)
- 互斥同步是最常用的一种线程安全实现方式,它通过同步机制保证同一时刻只有一个线程能够访问共享资源。在Java中,主要通过
synchronized
关键字和ReentrantLock
类来实现互斥同步。 synchronized
关键字:synchronized
关键字可以修饰方法或者代码块。当修饰方法时,整个方法体都被同步,例如:
- 互斥同步是最常用的一种线程安全实现方式,它通过同步机制保证同一时刻只有一个线程能够访问共享资源。在Java中,主要通过
public class SynchronizedMethodExample {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
- 在上述代码中,`increment` 方法和 `getCount` 方法都被 `synchronized` 修饰,保证了在多线程环境下,对 `count` 变量的访问和修改是线程安全的。当一个线程进入 `increment` 方法时,其他线程就不能同时进入 `increment` 方法或者 `getCount` 方法。
- `synchronized` 关键字也可以修饰代码块,这样可以更细粒度地控制同步范围:
public class SynchronizedBlockExample {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
synchronized (lock) {
return count;
}
}
}
- 在上述代码中,`increment` 方法和 `getCount` 方法通过 `synchronized` 块同步,锁对象是 `lock`。这样,只有获取到 `lock` 对象锁的线程才能进入相应的 `synchronized` 块,从而保证了对 `count` 变量的线程安全访问。
ReentrantLock
类:ReentrantLock
类提供了与synchronized
关键字类似的功能,但它更加灵活。ReentrantLock
支持公平锁和非公平锁,默认是非公平锁。非公平锁在获取锁时,允许新的线程插队,而公平锁则按照线程请求的顺序分配锁。- 例如,以下代码使用
ReentrantLock
实现线程安全的计数器:
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();
}
}
}
- 在上述代码中,`increment` 方法和 `getCount` 方法通过 `lock.lock()` 获取锁,在操作完成后通过 `lock.unlock()` 释放锁。使用 `try - finally` 块保证即使在操作过程中抛出异常,锁也能被正确释放。
3. 非阻塞同步(Non - blocking Synchronization)
- 非阻塞同步是一种不使用锁的同步方式,它通过硬件提供的原子操作(如
compare - and - swap
,简称 CAS)来实现线程安全。在Java中,java.util.concurrent.atomic
包下的类就是基于非阻塞同步实现的。 - 例如,
AtomicInteger
类的incrementAndGet
方法就是基于 CAS 操作实现的:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerExample {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; 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: " + count.get());
}
}
AtomicInteger
类内部使用了Unsafe
类提供的 CAS 操作来实现原子性的自增操作。CAS 操作包含三个参数:内存位置、预期值和新值。它会比较内存位置的值与预期值,如果相等则将内存位置的值更新为新值,否则不做任何操作,并返回操作是否成功。这种方式避免了使用锁带来的线程阻塞和上下文切换开销,在高并发环境下具有更好的性能。
线程安全的注意事项
- 避免死锁(Deadlock Avoidance)
- 死锁是指两个或多个线程相互等待对方释放资源,从而导致所有线程都无法继续执行的情况。在使用同步机制时,要特别注意避免死锁。
- 例如,以下代码可能会导致死锁:
public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread1 acquired lock1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread1 acquired lock2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread2 acquired lock2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println("Thread2 acquired lock1");
}
}
});
thread1.start();
thread2.start();
}
}
- 在上述代码中,
thread1
先获取lock1
,然后尝试获取lock2
,而thread2
先获取lock2
,然后尝试获取lock1
。如果thread1
先获取了lock1
,thread2
先获取了lock2
,那么两个线程就会相互等待对方释放锁,从而导致死锁。 - 为了避免死锁,可以遵循以下原则:
- 按顺序获取锁:所有线程按照相同的顺序获取锁,例如都先获取
lock1
,再获取lock2
。 - 限时获取锁:使用
tryLock
方法限时获取锁,如果在规定时间内无法获取到锁,则放弃并进行其他操作。
- 按顺序获取锁:所有线程按照相同的顺序获取锁,例如都先获取
- 锁的粒度(Lock Granularity)
- 锁的粒度是指锁所保护的代码块的大小。锁的粒度过大,会导致多个线程竞争锁的概率增加,从而降低并发性能;锁的粒度过小,又可能会因为频繁地获取和释放锁而增加开销。
- 例如,以下代码中锁的粒度过大:
public class LargeLockGranularityExample {
private int[] data = new int[10000];
private final Object lock = new Object();
public void updateData(int index, int value) {
synchronized (lock) {
data[index] = value;
}
}
public int getData(int index) {
synchronized (lock) {
return data[index];
}
}
}
- 在上述代码中,整个
updateData
和getData
方法都被锁保护,即使不同线程操作的是数组的不同位置,也会因为竞争锁而降低并发性能。可以通过减小锁的粒度来提高并发性能,例如:
public class SmallLockGranularityExample {
private int[] data = new int[10000];
private final Object[] locks = new Object[data.length];
public SmallLockGranularityExample() {
for (int i = 0; i < locks.length; i++) {
locks[i] = new Object();
}
}
public void updateData(int index, int value) {
synchronized (locks[index]) {
data[index] = value;
}
}
public int getData(int index) {
synchronized (locks[index]) {
return data[index];
}
}
}
- 在上述代码中,为数组的每个元素都分配了一个锁,不同线程操作不同元素时不会竞争同一个锁,从而提高了并发性能。
- 线程本地存储(Thread - Local Storage)
- 线程本地存储是一种让每个线程都有自己独立的变量副本的机制。在Java中,可以使用
ThreadLocal
类来实现线程本地存储。 - 例如,以下代码使用
ThreadLocal
为每个线程提供独立的计数器:
- 线程本地存储是一种让每个线程都有自己独立的变量副本的机制。在Java中,可以使用
public class ThreadLocalExample {
private static ThreadLocal<Integer> threadLocalCount = ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
threadLocalCount.set(threadLocalCount.get() + 1);
}
System.out.println("Thread1 count: " + threadLocalCount.get());
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 2000; i++) {
threadLocalCount.set(threadLocalCount.get() + 1);
}
System.out.println("Thread2 count: " + threadLocalCount.get());
});
thread1.start();
thread2.start();
}
}
- 在上述代码中,
threadLocalCount
是一个ThreadLocal
对象,每个线程都有自己独立的threadLocalCount
副本。线程1和线程2对threadLocalCount
的操作不会相互影响,从而保证了线程安全。
通过深入理解Java内存模型和掌握线程安全的实现方式,开发人员可以编写出高效、可靠的多线程Java程序。在实际应用中,需要根据具体的业务需求和性能要求,选择合适的线程安全策略。