进程和线程是操作系统中的两个基本概念,进程是操作系统中资源分配的基本单位,拥有独立的内存空间、资源和进程控制块;线程则是进程内的执行单元,是处理器调度的基本单位,不拥有系统资源,可以访问隶属于进程的资源。线程是进程的基本组成单位,一个程序至少有一个进程,一个进程至少有一个线程;线程之间可以共享内存和资源,从而提高程序的并发性和执行效率;在多核处理器系统中,多个线程可以在不同的核心上并行执行,充分利用计算机的资源。
在一个 CPU 核心上,多个线程共享 CPU 时间片,属于一个线程的时间片用完后,就会切换到另一个线程运行。此时需要保存当前线程的状态信息,包括程序计数器、寄存器、栈指针等,以便下次继续执行该线程时能够正确恢复到原本执行状态。同时,需要将切换到的线程的状态信息也恢复,以便于该线程能够正确运行,这个过程叫做上下文切换。
上下文切换会带来额外的开销,因为在线程切换的过程中,会暂停当前线程的工作来保存上下文信息然后恢复切换到的线程的上下文信息,这个上下文切换回给CPU时钟周期带来额外的管理调度开销,在现代CPU上这个开销预计在30μs左右,如果这30μs用于线程的运行,线程中的任务可能已经结束了。因此过多的上下文切换会降低运行效率,线程的总数量并不是越多越好,合理的控制线程数量可以减少上下文切换的次数,才能让多线程发挥真正的作用。
线程的状态
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行run()方法的Java代码;
- Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
- Terminated:线程已终止,因为run()方法执行完毕。
新建线程并执行代码
Java有两种新建线程并执行代码的方法。
方法一:从Thread派生一个自定义类,然后覆写run()方法
1 | public class Main { |
方法二:创建Thread实例时,传入一个Runnable实例
1 | public class Main { |
新建线程后需要使用 thread.run()
或者 thread.start()
方法启动线程。thread.start()
方法用于启动一个线程,当一个线程被启动时,它会进入就绪状态并等待CPU时间片,一旦获得时间片,线程就会开始执行thread.run()
方法。thread.start()
方法不会阻塞调用它的线程,而且可以多次调用,因为它涉及到创建和启动新线程。每个线程只能调用一次thread.start()
,多次调用不会产生任何效果。如果直接调用run()
方法,程序会顺序执行,直到run()
方法执行完毕,这不会达到多线程的目的。run()
方法可以在同一个线程中多次调用,但每次调用都会阻塞当前线程,直到前一次调用完成。
暂停线程
线程在运行后,如果想要暂停,可以使用 thread.wait()
或者 thread.sleep()
方法暂停线程。其中 thread.wait()
只能在同步方法或者同步块中使用,而且会释放持有的对象锁,调用方法暂停线程之后线程就进入了 Waiting 状态,直到被唤醒为止;而 thread.sleep()
方法可以在任何地方使用,不会释放持有的对象锁,调用方法暂停线程之后就进入了 Timed Waiting 状态,等到指定时间之后会尝试再次获取时间片执行任务。
当一个线程暂停进入等待状态后,就必须等待其他线程调用 notify()
或者 notifyAll()
才会从等待队列中被移出,使用 notifyAll()
可以唤醒所有处于等待状态的线程,使其重新进入锁的争夺队列中,而 notify()
只能唤醒一个线程。被唤醒的线程只是进入争夺队列,不一定立即就可以获得 CPU 时间片开始执行,因为 wait()
方法会释放对象锁。
还有 thread.yield()
方法能让当前线程从运行状态进入到就绪状态,表示当前线程愿意放弃目前占有的CPU时间片,重新进行竞争,但并不能保证下一个被调度的线程不是当前线程。
线程优先级
当多个线程同时运行时,可以设置线程的优先级来让指定线程有较高的概率抢占CPU时间片,设置完优先级后就由 JVM 自动把1(低)~10(高)的优先级映射到操作系统实际优先级上。优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但无法确保高优先级的线程一定会先执行。
1 | // 10表示最高优先级(Thread.MAX_PRIORITY),1表示最低优先级(Thread.MIN_PRIORITY),5是普通优先级(Thread.NORM_PRIORITY,默认) |
中断线程的方法
当线程处于运行状态时,调用 t.interrupt()
即可结束指定的线程。当子线程运行时,t.join()
会让当前线程进入等待状态,后面的代码会等到线程结束才会运行,如果主动终止线程,那么join方法会抛出 InterruptedException
。
1 | public class Main { |
线程终止的原因
- 线程正常终止:run()方法执行到return语句返回;
- 线程意外终止:run()方法因为未捕获的异常导致线程终止;
- 对某个线程的Thread实例调
stop()
或者interrupt()
方法强制终止。
守护线程
在JVM中,如果有非守护线程还未结束,那么JVM进程就无法正常结束,等到所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
1 | Thread t = new MyThread(); |
线程同步
由于线程的调度由操作系统决定,程序本身无法决定,所以任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。如果多个线程同时读写共享变量,大概率会出现数据不一致的问题。多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行,让多个线程之间按照顺序访问同一个共享资源,避免因为并发冲突导致的问题。
同步的本质就是给指定对象加锁或进行其他并发限制、等待限制,达到条件后才能继续执行后续代码。如果使用锁需要注意加锁对象必须是同一个实例,因为 JVM 只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取;单条原子操作的语句不需要同步,例如赋值给基元、引用类型,因为他们都是原子操作。但如果是多行赋值语句就要保证是原子操作。
使用 synchronized 锁
synchronized 是 Java 中的一个关键字,主要用于加锁,可用于修饰代码块或者方法,保证同一时间只要一个线程访问该代码块或方法,其他线程访问时需要等待锁释放。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i = 0; i < 10000; i++) {
synchronized(Counter.lock) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i = 0; i < 10000; i++) {
synchronized(Counter.lock) {
Counter.count -= 1;
}
}
}
}让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把 synchronized 逻辑封装在调用的对象中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Counter {
private int count = 0;
public void add(int n) {
synchronized(this) {
count += n;
}
}
public void dec(int n) {
synchronized(this) {
count -= n;
}
}
public int get() {
return count;
}
}使用 ReentrantLock 锁
ReentrantLock 是 JDK 中提供的一个类,用于获取锁保证同一时间只要一个线程可以访问共享资源,支持公平锁(默认非公平),可打断,可重入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class Example {
private final ReentrantLock lock = new ReentrantLock();
public void exampleMethod() {
try {
lock.lock();
// do something...
} finally {
if(lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}使用 Semaphore 控制并发访问数量
Semaphore 是 JDK 中提供的一个类,允许多个线程同时访问共享资源,主要用于控制同时访问共享资源的线程数量,限制访问线程数量避免系统资源被过度占用。
1
2
3
4
5
6
7
8
9
10
11
12
13public class Example {
private final Semaphore semaphore = new Semaphore(3);
public void exampleMethod() throws InterruptedException {
try {
semaphore.acquire();
// do something...
} finally {
semaphore.release();
}
}
}使用 CountDownLatch 等待其他线程执行完成
CountDownLatch 是 JDK 中提供的一个类,允许多个线程等待所有线程执行完毕之后再继续执行后续代码,可以用于线程之间的协调和通讯,只能一次性使用。
1
2
3
4
5
6
7
8
9
10
11public class Example {
private final CountDownLatch latch = new CountDownLatch(3);
public void exampleMethod() throws InterruptedException {
// 每个线程执行 latch.countDown() 更新计数并等待
latch.countDown();
// 等待所有线程工作完成
latch.await();
}
}使用 CyclicBarrier 等待所有线程
CyclicBarrier 是 JDK 中提供的一个类,可以译为循环栅栏,允许多个线程等待所有线程都执行完毕之后再继续执行后续代码,可以用于线程之间的协调和通讯,可重复循环使用。
1
2
3
4
5
6
7
8
9
10
11public class Example {
private final CyclicBarrier barrier = new CyclicBarrier(3);
public void exampleMethod() throws InterruptedException {
// 每个线程执行 barrier.await() 更新计数并等待
barrier.await();
// 所有线程到达该栅栏点后同时继续执行
// do something...
}
}
线程安全
通过合理的设计、数据封装、加锁可以让一个类变为在多线程并发读写的情况下不会出现数据不一致的问题,这种操作叫做“线程安全”;如只读取一个变量,或者将非原子操作包装为原子操作。一个类没有特殊说明,默认不是线程安全(non-thread-safe)。