文章目录
- 介绍
- 创建新线程
- 线程的状态
- 中断线程
- 守护线程
- 线程同步
- 同步方法
- 死锁
- wait和notify
- ReentrantLock
- condition
- ReadWriteLock
- StampedLock
- Semaphore
- 线程池
- Future
- CompletableFuture
介绍
计算机中,一个任务称为一个进程,某些进程内部还需要同时执行多个子任务,子任务称为线程。
一个进程可以包含一个或多个线程,但至少会有一个线程
操作系统调度的最小单位是线程,Windows和Linux都采用抢占式多任务,如何调度线程由操作系统决定
特点:
- 创建进程的开销大,但进程稳定性高,一个进程崩溃不会影响其他进程
- 任何一个线程崩溃会直接导致整个进程崩溃,线程间通信快
Java程序实际上是JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,可以启动多个线程。JVM还有负责垃圾回收的其他工作线程。
创建新线程
创建新线程需要实例化Thread实例,然后调用它的start()方法
package ThreadTest;
public class ThreadStudy {
public static void main(String[] args) {
Thread t=new Thread();
t.start();
}
}
如果希望新线程能执行指定的代码:
1、从Thread派生一个自定义类,覆写run()方法
2、创建Thread实例时,传入一个Runnable实例
package ThreadTest;
public class ThreadStudy {
public static void main(String[] args) {
Thread t=new Thread();
t.start();
// 方法1
MyThread myThread=new MyThread();
myThread.start();
// 方法2
Thread thread=new Thread(new MyRunnable());
thread.start();
// 方法2 lambda写法
Thread thread2=new Thread(()->{
System.out.println("start thread2 lambda!");
});
thread2.start();
// start mythread!
// start myRunnable!
// start thread2 lambda!
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("start mythread!");
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start myRunnable!");
}
}
线程优先级
Thread.setPriority(int n);//1~10,默认5,1最低
优先级高的线程被操作系统调度的优先级高,操作系统对高优先级线程可能调度更频繁,但不能通过设置优先级来确保高优先级的线程一定会先执行。
线程的状态
Java线程的状态有以下几种:
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行run()方法的Java代码;
- Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
- Terminated:线程已终止,因为run()方法执行完毕。
线程启动后,在四个状态切换,直到变成terminated状态,线程终止
线程终止的原因:
- 正常终止,run()执行到return返回
- 意外终止,run()因为没捕获异常导致线程终止
- stop(),对某个线程实例调用stop()方法强制终止
t.join()方法可以让一个线程等待t结束后执行
中断线程
t.interrupt()方法可以中断线程
interrupt()方法仅仅向t线程发出了“中断请求”,至于t线程是否能立刻响应,要看具体代码。
另一个常用的中断线程的方法是设置标志位。通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束
public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(1);
t.running = false; // 标志位置为false
}
}
class HelloThread extends Thread {
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n ++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}
HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。
volatile关键字的目的是告诉虚拟机:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
守护线程
所有线程都运行结束,JVM退出,进程结束。
有一种线程的目的是无限循环,其他线程结束,JVM想要结束,就要有专门的线程负责结束这个无限循环的线程。
这种就是守护线程(Daemon Thread)
守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
创建守护线程
和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程
Thread t = new MyThread();
t.setDaemon(true);
t.start();
守护线程不能持有任何需要关闭的资源,因为虚拟机退出时,守护线程没有机会关闭文件,会导致数据丢失。
线程同步
多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。
如果多个线程同时读写共享变量,会出现数据不一致的问题。
对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
通过加锁和解锁的操作,能保证多条指令总在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
Java程序使用synchronized关键字对一个对象进行加锁:
synchronized(lock) {
n = n + 1;
}
package ThreadTest;
public class ThreadSync {
public static void main(String[] args) throws InterruptedException {
Thread add = new AddThread();
Thread 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{
@Override
public void run() {
for (int i=0;i<10000;i++){
synchronized (Counter.lock) {
Counter.count+=1;
}
}
}
}
class DecThread extends Thread{
@Override
public void run() {
for(int i=0;i<10000;i++){
synchronized (Counter.lock){
Counter.count-=1;
}
}
}
}
表示用Counter.lock实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { … }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count变量进行读写就不可能同时进行。
使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。
不需要synchronized的操作
JVM规范定义了几种原子操作:
- 基本类型(long和double除外)赋值,例如:int n = m;
- 引用类型赋值,例如:List list = anotherList。
同步方法
让线程自己封锁对象会使代码逻辑混乱,也不利于封装,更好的方法是把synchronized逻辑封装起来。
public class 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;
}
}
synchronized锁住的对象是this,即当前实例,这会使得创建多个实例的时候,它们之间互不影响,可以并发执行
如果一个类被设计为允许多线程正确访问,就说这个类是”线程安全的“
Java标准库的java.lang.StringBuffer,一些不变类,例如String,Integer,LocalDate,所有的成员变量都是final,多线程同时访问时只能读不能写,也是线程安全的
类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的
当锁住的是this实例时,实际上可以用synchronized修饰这个方法
public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}
public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁
对static方法添加synchronized,锁住的是该类的Class实例。
死锁
Java的线程锁是可重入锁,也就是JVM允许同一个线程重复获取同一个锁,
获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}
线程1和线程2如果分别执行add()和dec()方法时:
线程1:进入add(),获得lockA;
线程2:进入dec(),获得lockB。
随后:
线程1:准备获得lockB,失败,等待中;
线程2:准备获得lockA,失败,等待中。
两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
如何避免死锁?
线程获取锁的顺序要一致,即严格按照先获取lockA,再获取lockB的顺序
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
wait和notify
在synchronized内部可以调用wait()使线程进入等待状态;
必须在已获得的锁对象上调用wait()方法;
在synchronized内部可以调用notify()或notifyAll()唤醒其他等待线程;
必须在已获得的锁对象上调用notify()或notifyAll()方法;
已唤醒的线程还需要重新获得锁后才能继续执行。
ReentrantLock
synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。
java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁
ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。
和synchronized不同的是,ReentrantLock可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。
condition
synchronized可以配合wait和notify实现线程在条件不满足时等待,条件满足时唤醒
ReentrantLock使用Condition对象来实现wait和notify的功能
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
-
await()会释放当前锁,进入等待状态;
-
signal()会唤醒某个等待线程;
-
signalAll()会唤醒所有等待线程;
ReadWriteLock
ReentrantLock保证了只有一个线程可以执行临界区代码,任何时刻,只允许一个线程修改,但get操作实际上允许多个线程同时调用
ReadWriteLock可以解决这个问题,它保证:
- 只允许一个线程写入(其他线程既不能写入也不能读取);
- 没有写入时,多个线程允许同时读(提高性能)。
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}
public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}
StampedLock
ReadWriteLock解决了多线程同时读,但只有一个线程能写的问题,
但是如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的锁
要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。
StampedLock和ReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入
这样读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
获取乐观读锁,返回版本号,验证版本号,成功就继续后续操作,如果读过程有写入,版本号变化,通过获取悲观读锁再次读取
Semaphore
锁的目的是保护一种受限资源,保证同一时刻只有一个线程能访问(ReentrantLock),或者只有一个线程能写入(ReadWriteLock)
还有一种受限资源,它需要保证同一时刻最多有N个线程能访问,比如同一时刻最多创建100个数据库连接,最多允许10个用户下载等。
这种限制数量的锁,如果用Lock数组来实现,就太麻烦了。
这种情况就可以使用Semaphore
public class AccessLimitControl {
// 任意时刻仅允许最多3个线程获取许可:
final Semaphore semaphore = new Semaphore(3);
public String access() throws Exception {
// 如果超过了许可数量,其他线程将在此等待:
semaphore.acquire();
try {
// TODO:
return UUID.randomUUID().toString();
} finally {
semaphore.release();
}
}
}
线程池
创建线程需要操作系统资源,频繁创建和销毁大量线程需要消耗大量时间。
可以把很多小任务让一组线程来执行,而不是一个任务对于一个新线程,这种能接收大量小任务并进行分发处理的就是线程池。
线程池内部维护了若干个线程,没有任务的时候,这些线程都处于等待状态。有新任务,就分配一个空闲线程执行。所有线程都处于忙碌状态,新任务要么放入队列等待,要么增加一个新线程进行处理。
Java标准库提供了ExecutorService接口表示线程池
ExecutorService只是接口,Java标准库提供的几个常用实现类有:
- FixedThreadPool:线程数固定的线程池;
- CachedThreadPool:线程数根据任务动态调整的线程池;
- SingleThreadExecutor:仅单线程执行的线程池。
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}
class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}
ScheduledThreadPool
任务本身固定,需要反复执行的,可以使用ScheduledThreadPool。放入ScheduledThreadPool的任务可以定期反复执行。
Future
Runnable接口有个问题,它的方法没有返回值。如果任务需要一个返回结果,那么只能保存到变量,还要提供额外的方法读取,非常不便。所以,Java标准库还提供了一个Callable接口,和Runnable接口比,它多了一个返回值
class Task implements Callable<String> {
public String call() throws Exception {
return longTimeCalculation();
}
}
ExecutorService executor = Executors.newFixedThreadPool(4);
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞
一个Future接口表示一个未来可能会返回的结果,它定义的方法有:
- get():获取结果(可能会等待)
- get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;
- cancel(boolean mayInterruptIfRunning):取消当前任务;
- isDone():判断任务是否已完成。
CompletableFuture
Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。
CompletableFuture的优点是:
- 异步任务结束时,会自动回调某个对象的方法;
- 异步任务出错时,会自动回调某个对象的方法;
- 主线程设置好回调后,不再关心异步任务的执行。