前言
我们主要看如何使用 Java 的多线程,以及线程安全。
先来看看 Java 多线程的用法。
Java 多线程
Thread
java
Thread thread = new Thread(){
@Override
public void run() {
System.out.println("Thread is running");
}
};
thread.start();
首先,创建 Thread
对象,然后调用该对象的 start()
。这样就可以将 run
方法中的代码,放在创建的线程中去执行。
为什么这样就能够将任务放在后台去执行?
进到 start
方法内部,发现调用的是 start0
方法:
java
private native void start0();
start0
被 native
修饰,表明该方法的实现和平台相关,是由虚拟机调度操作系统去完成的。
此方法会让虚拟机开启新的线程,然后执行 run
方法的代码。
补充:
进程(Process)是操作系统进行资源分配的基本单位,是一个动态执行的程序及其所有系统资源的集合。
而线程(Thread)是进程内部执行代码的最小单位,一个进程可以包含一个或多个线程。
线程之间可以共享同一进程的资源,但进程之间互相独立。
Runable
java
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Runnable is running");
}
};
Thread thread = new Thread(runnable);
thread.start();
创建 Runable
对象,传给 Thread
对象来创建并启动线程。
传入的 Runable
对象会在 Thread.run
方法中被执行。
java
// Thread.java
@Override
public void run() {
Runnable task = holder.task;
if (task != null) { // task 就是传入的 Runnable 对象
Object bindings = scopedValueBindings();
runWith(bindings, task);
}
}
这两种写法的效果是一样的,但后者的重用性更高。
不过,在实际使用中,这两种方式都不常用。
ThreadFactory
java
AtomicInteger count = new AtomicInteger(0);
ThreadFactory threadFactory = new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "thread-" + (count.incrementAndGet())); // 使用原子类保证 ++count 线程安全
}
};
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " is running");
}
};
for (int i = 0; i < 10; i++) {
Thread thread = threadFactory.newThread(runnable);
thread.start();
}
ThreadFactory
使用内部 newThread
方法创建线程,常用于统一管理线程的创建,比如统一命名或计数。
Executor
java
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("hello world");
}
};
Executor executor = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executor.execute(runnable);
}
创建线程的工作在 newCachedThreadPool
方法中。
java
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
ExecutorService
的shutdown
和shutdownNow
方法可用于停止所有任务。shutdownNow
是立即停止,会尝试中断正在执行的任务;而shutdown
是稍后停止,不再接受新任务,会执行完正在执行或者在排队等待的任务。
实际上,是在 ThreadPoolExecutor
这个线程池对象中。
构造方法的参数分别是核心线程数、最大线程数、保持活跃时间,时间单位,任务阻塞队列。如果当前线程数大于核心线程数,那么超出的线程会在空闲达到活跃时间后被回收。
另外,我们可以调用 Executors
的 newFixedThreadPool
静态方法来创建一个固定线程数量的线程池,可用于处理井喷式任务。
调用 Executors
的 newSingleThreadExecutor
方法可以创建一个只有一个线程的线程池,可用于处理需要在后台执行的细小任务。
Callable
java
Callable<String> callable = new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(3000);
return "hello world";
}
};
ExecutorService executor = Executors.newSingleThreadExecutor();
// 提交任务并获取Future对象
Future<String> future = executor.submit(callable);
try {
// 获取结果 (此方法会阻塞当前线程)
String result = future.get();
System.out.println("result is " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
我们可以使用 Callable
来获取线程执行的返回值,但调用 Future.get()
会阻塞当前线程,直到结果返回。
那这么做的意义何在?
其实我们可以在循环中,主动检查任务是否完成,如果未完成就执行其他任务,否则取出结果,这样获取结果的过程就是非阻塞的了。
java
while (true) {
// ... 执行其他任务 ...
System.out.println("正在执行其他任务...");
Thread.sleep(500); // 模拟耗时
if (future.isDone()) { // 检查后台任务是否已完成
try {
// 任务已完成,获取返回结果
String result = future.get();
System.out.println("result is " + result);
break;
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
}
线程同步和线程安全
示例一 (可见性问题)
java
public class Main {
public static boolean isRunning = true;
public static void main(String[] args) {
new Thread() {
@Override
public void run() {
while (isRunning) { // 子线程读取 isRunning
}
}
}.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 主线程修改 isRunning
isRunning = false;
}
}
按照我们预想来说,程序会在一秒后停止,但实际却不会,这是为什么?
要明白这一点,首先得知道 Java 内存模型:线程和程序都会有自己独立的内存空间,线程的叫工作内存,程序的叫做主内存。当线程需要修改程序中的变量时,会先拷贝一份到本地(工作内存中),在本地修改后,再将修改后的数据同步到主内存中。
在上述代码中,虽然主线程将 isRunning
变量的值修改为了 false
,并同步到了主内存中,但子线程却一直持有自己工作内存中的旧数据(isRunning == true
),并且没有被通知去主内存中拿取新数据,所以导致子线程中的循环不会停止,这就是线程的可见性问题。
我们可以加上 volatile
关键字来解决,它会保证变量的可见性。
-
也就是每次读数据前,强制先从主内存中拿取最新的数据到本地,让本地的缓存失效;
-
每次写数据后,强制将最新的本地数据立即同步(刷新)到主内存中。
java
public static volatile boolean isRunning = true;
现在运行程序,一秒就会结束。
示例二 (原子性问题)
java
public class Main {
public static int x = 0;
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
x++;
}
System.out.println("Thread A: x is " + x);
}).start();
new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
x++;
}
System.out.println("Thread B: x is " + x);
}).start();
}
}
预想中,总有一个线程会打印两百万,但实际却是:
less
// 执行第 n 次
Thread A: x is 1315887
Thread B: x is 1315887
// 执行第 n+1 次
Thread A: x is 704550
Thread B: x is 1363200
难道是线程可见性(同步性)的问题吗?我们加上 volatile
关键字试试,发现结果并没有改变。
volatile
只能保证可见性,不能保证原子性。
其实这是因为 x++
不是一个原子操作,它会先读取 x 的当前值,然后计算 x+1,最后将计算结果赋给 x。
因为 x++
可被拆分,所以导致了这个问题。
比如当 x=5 时,线程 A 读取 x=5
,此时发生线程切换,线程 B 开始执行。B 将三步都执行完(读取、计算、写回),此时主内存中 x=6。切回线程 A,A 会接着执行,它会将读取的旧值 5 计算出结果 6,然后将 6 写回 x。这样就使得,两个线程都执行了 x++
,但 x 的值却只增加了 1,导致一次累加丢失。
循环反复,会导致累加不断丢失,就造成了最终结果并不是两百万。
所以我们需要让 x 的读、改、写操作操作合并为原子操作,可以使用 synchronized
关键字:
java
public class Main {
public static int x = 0;
// 增加同步静态方法
public synchronized static void increment() {
x++;
}
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
increment();
}
System.out.println("Thread A: x is " + x);
}).start();
new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
increment();
}
System.out.println("Thread B: x is " + x);
}).start();
}
}
synchronized
既可以保证可见性,又能保证原子性。它会让该方法同一时刻只能被一个线程执行。
现在,运行就能够看到正确结果,例如:
less
Thread A: x is 1868686
Thread B: x is 2000000
我们也可以使用原子类来解决,比如之前看到的 AtomicInteger
。
java
public class Main {
// 直接使用 AtomicInteger,初始值为 0
public static AtomicInteger x = new AtomicInteger(0);
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
x.incrementAndGet(); // 调用原子的"加一并获取"方法
}
System.out.println("Thread A: x is " + x.get());
}).start();
new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
x.incrementAndGet();
}
System.out.println("Thread B: x is " + x.get());
}).start();
}
}
它的 incrementAndGet()
是原子性的,底层使用 CAS(比较并交换)机制实现。在并发并不激烈时,性能会优于 synchronized
这种重量级锁。
示例三 (Monitor 锁对象)
java
public class Main {
public int count = 0;
public int number = 0;
public String id;
private synchronized void add() {
count++;
number++;
}
private synchronized void minus() {
count--;
number--;
}
private synchronized void randomId() {
id = UUID.randomUUID().toString();
}
}
为了不让修改变量(资源)的方法被多个线程同时访问,且保证线程同步性,我们给每个方法都加上了 synchronized
关键字。
实际上,Java 会给加上了 synchronized
关键字的方法设置监视器 (monitor)。对于非静态方法,默认设置的监视器为当前类对象 (this
)。
对于静态方法,默认的监视器是当前类的字节码 (
Main.class
)。
因为这三个方法共享了同一个监视器,所以会导致有一个线程调用 add
方法时,其他线程无法调用 minus
(很合理,因为操作了相同的资源 count
和 number
), 同时,其他线程也无法调用 randomId
方法。
但这并不是我们想要的,因为修改 id
和修改 count/number
并不排斥,其他线程调用 randomId
方法并不会影响到当前线程的线程安全。
这时需要用到 synchronized
代码块,为不同的资源指定不同的锁(监视器对象):
java
public class Main {
public int count = 0;
public int number = 0;
public String id;
private final Object monitorCountNumber = new Object();
private final Object monitorId = new Object();
private void add() {
synchronized (monitorCountNumber) {
count++;
number++;
}
}
private void minus() {
synchronized (monitorCountNumber) {
count--;
number--;
}
}
private void randomId() {
synchronized (monitorId) {
id = UUID.randomUUID().toString();
}
}
}
这样也能保证线程安全,add
或 minus
方法被一个线程访问时,会锁住 monitorCountNumber
;而 randomId
可被另一个线程同时访问,因为它锁的是 monitorId
。
synchronized
实际上保护的是资源,通过给资源上锁来实现互斥访问。
模型图为:
死锁 (Deadlock)
死锁是指两个或多个线程在执行过程中,因抢夺资源而造成的互相等待的现象。
只有涉及多个锁(单个锁是不会发生的),并且线程获取锁的顺序不一致时,才会发生。例如:
java
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public void runThreadA() {
synchronized (lockA) { // 线程A 先锁 A
System.out.println("Thread A got lock A");
try {
Thread.sleep(100);
} catch (Exception e) {
}
System.out.println("Thread A trying to get lock B...");
synchronized (lockB) { // 再尝试锁 B
System.out.println("Thread A got lock B");
}
}
}
public void runThreadB() {
synchronized (lockB) { // 线程B 先锁 B
System.out.println("Thread B got lock B");
try {
Thread.sleep(100);
} catch (Exception e) {
}
System.out.println("Thread B trying to get lock A...");
synchronized (lockA) { // 再尝试锁 A
System.out.println("Thread B got lock A");
}
}
}
}
线程 A 尝试锁定锁 B 时,发现锁 B 被线程 B 持有,所以线程 A 会等待;同时,线程 B 尝试锁定锁 A 时,发现锁 A 被线程 A 持有,所以线程 B 会等待。
A 在等 B 释放锁,B 也在等 A 释放锁,两者都无法进行下去,程序被永久挂起了,这就是死锁。
双重检查锁 (DCL)
我们再来看看双重检查锁机制,它主要用于懒加载的单例模式,例如:
java
public class Singleton {
// 必须加 volatile 关键字
private static volatile Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
// 第一次检查:如果已存在,避免不必要的同步,提高性能
if (instance == null) {
synchronized (Singleton.class) {
// 第二次检查:确保锁内的线程只创建一次实例
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
为什么代码是这样的?
java
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
正常来说这样就行了,但如果有两个线程同时访问 getInstance()
方法,同时执行到了 if (instance == null)
语句,都判断为 true,这样会导致 Singleton
实例被先后创建两次,破坏单例。
你当然可以给整个方法加上 synchronized
关键字,但这会降低性能。
java
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
如果只是给创建实例的代码加上锁,还是会导致问题:两个线程都进入了外层的 if 检查。A 拿到锁,会创建实例,A 释放锁后,B 会拿到锁,又会创建一个实例。
所以需要将第二次检查实例是否为空的代码加上:
java
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
// 第二次检查:确保实例只创建一次
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
现在还有一个问题,instance = new Singleton()
并非原子操作。它会分为三步:
- 为 instance 变量分配内存空间
- 调用 Singleton 的构造函数,初始化字段
- 让 instance 指向分配的内存地址
JVM 为了优化,可能会进行指令重排,将执行的顺序改变,变为 1->3->2。
当线程 A 执行完 1 和 3 后,线程 B 执行到最外层 if 语句时,会认为此时 instance
不为 null,直接返回这个实例。虽然 instance
指向了内存地址,但对象内部的字段还未初始化,这会导致后续使用出错。
所以还要加上 volatile
关键字,它能够禁止这种指令重排,保证其他线程拿到的必定是完整的、初始化过的实例。
这就是为什么最终代码会是这样的。
读写锁
另外我们可以使用可重入锁 (ReentrantLock
),来手动上锁和释放锁,像这样:
java
ReentrantLock lock = new ReentrantLock();
public void func(){
// 上锁
lock.lock();
try {
// 必须放在 try...finally 中,确保锁一定会被释放
System.out.println("do something");
} finally {
// 释放锁
lock.unlock();
}
}
为什么要用它,因为它更灵活,比如可以派生出读写锁 (ReentrantReadWriteLock
)。
我们来分析一下线程安全问题会发现:
- 在一个线程写操作时,其他线程不能进行写操作;
- 在一个线程写操作时,其他线程不能进行读操作;
- 在一个线程读操作时,其他线程也不能进行写操作;
- 但一个线程进行读操作时,其他线程也能进行读操作。
synchronized
和 ReentrantLock
都是独占锁,无法区分读写,只要上锁,读和写都会被阻塞。但在读多写少的场景中,允许读-读并发,就能极大地提高性能。
这时,可重用读写锁就派上用处了,它对资源有着更加精细的控制:
java
public class ReadWriteDemo {
private String data = "initial data";
// 创建一个读写锁
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 从中获取读锁
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
// 从中获取写锁
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
// 读操作,使用读锁(允许多个线程同时读)
public void printData() {
readLock.lock();
try {
System.out.println(data);
} finally {
readLock.unlock();
}
}
// 写操作,使用写锁(独占,同一时间只允许一个线程写)
public void setData(String data) {
writeLock.lock();
try {
this.data = data;
} finally {
writeLock.unlock();
}
}
}