Java多线程基本概念, 常用关键字及常用锁类
常用关键字及常用锁类后续还会补充, 先挖个坑
- 线程的状态
-
New:新创建的线程,尚未执行;
-
Runnable:运行中的线程,正在执行
run()
方法的Java代码; -
Blocked:运行中的线程,因为某些操作被阻塞而挂起;
-
Waiting:运行中的线程,因为某些操作在等待中;
-
Timed Waiting:运行中的线程,因为执行
sleep()
方法正在计时等待; -
Terminated:线程已终止,因为
run()
方法执行完毕。放两张图帮助理解:
注意Blocked和Waiting的区别(TODO: 待补充):
- Blocked: 等待同步锁(syncronized, ReentrantLock), 处于锁竞争状态, 但未获取到锁
- Waiting: 等待其他线程执行完成(join(), wait()), 会将持有的锁释放
- 守护线程
- 所有非守护线程都执行完毕后,无论有没有守护线程,JVM都会自动退出。
- 使用守护线程的一个常见例子是在后台执行周期性的任务,例如GC, 定时任务或日志清理等。
- 线程同步
- 临界区: 加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
对于语句:
n = n + 1;
看上去是一行语句,实际上对应了3条指令:
ILOAD
IADD
ISTORE
我们假设n
的值是100
,如果两个线程同时执行n = n + 1
,得到的结果很可能不是102
,而是101
,原因在于:
加锁后:
- JVM规范定义的几种原子操作(单行, 多行赋值还是需要同步的):
基本类型(long
和double
除外)赋值,例如:int n = m
;
引用类型赋值,例如:List<String> list = anotherList
。
long
和double
是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long
和double
的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。
- 死锁
一个线程可以获取一个可重入锁
后,再继续获取另一个可重入锁
:
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
的顺序,改写dec()
方法如下:
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
- 常用锁类
ReentrantLock
:可重入锁
, 和synchronized
类似
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
}
catch(...) {
...
}
finally {
lock.unlock();
}
}
优点:
- 更灵活,
lock()
和unlock()
可以跨多个不同的方法, 不同的代码块调用,tryLock()
可以高性能, 超过时间就不再等待tryLock()
可以提高安全性, 避免死锁缺点:
- 需要在
finally
代码块手动调用unlock()
- 需要自己处理异常
Condition
: 可以从Lock
对象的实例获取其Condition
(其它比如ReadWriteLock
是没有Condition
的),Condition
提供的await()
、signal()
、signalAll()
原理和synchronized
锁对象的wait()
、notify()
、notifyAll()
是一致的,并且其行为也是一样的
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();
}
}
}
和
tryLock()
类似,await()
可以在等待指定时间后,如果还没有被其他线程通过signal()
或signalAll()
唤醒,可以自己醒来if (condition.await(1, TimeUnit.SECOND)) { // 被其他线程唤醒 } else { // 指定时间内没有被其他线程唤醒 }
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
:乐观读锁
,不可重入锁
.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);
}
}
主要关注读的方法distanceFromOrigin()
, validate()
获取版本号,如果在读取过程中有写入,版本号和乐观读锁tryOptimisticRead()
的不同, 则获取悲观读锁, lock()
之后, 再重新获取一次最新值
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。
- Thread类常用方法
join()
: 等待线程执行完成, 可指定等待时间, 超过即不再等待;stop()
: 强行终止线程, 不推荐使用, 推荐用interrupt设置中断标记 TODO: 原因暂缓了解interrupt()
: 设置中断标记, 对于处在Waiting状态的线程, 会立刻抛出InterruptedException;
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}
class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}
main
线程通过调用t.interrupt()
从而通知t
线程中断,而此时t
线程正位于hello.join()
的等待中,此方法会立刻结束等待并抛出InterruptedException
。由于我们在t
线程中捕获了InterruptedException
,因此,就可以准备结束该线程。在t
线程结束前,对hello
线程也进行了interrupt()
调用通知其中断。如果去掉这一行代码,可以发现hello
线程仍然会继续运行,且JVM不会退出。
可使用自定义的变量来代替interrupt()
以避免抛出异常, 而且更加灵活, 注意要用volatile
修饰变量以保证其可见性
-
setDeamon()
/isDeamon()
: 设定/检查是否为守护线程 -
setPriority()
: 设定线程优先级1-10, 默认为5, 优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。 -
wait()
和notify()
/notifyAll()
:-
wait()
使线程进入等待状态,wait()
方法返回时需要重新获得锁 -
使用
notifyAll()
将唤醒所有当前正在this
锁等待的线程,而notify()
只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。通常来说,notifyAll()
更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()
会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。 -
调用
wait()
和notify()
/notifyAll()
的方法必须是synchronized的, 否则会抛出IllegalMonitorStateException
-
wait()
和notify()
/notifyAll()
都是object
类的方法, 必须在已获得的锁对象上调用它们
synchronized
解决了多线程竞争的问题, 但没有解决并没有解决多线程协调的问题
class TaskQueue { Queue<String> queue = new LinkedList<>(); public synchronized void addTask(String s) { this.queue.add(s); } public synchronized String getTask() { while (queue.isEmpty()) { } return queue.remove(); } }
while()
循环永远不会退出。因为线程在执行while()
循环时,已经在getTask()
入口获取了this
锁,其他线程根本无法调用addTask()
,因为addTask()
执行条件也是获取this
锁。- 结合一个完整的例子总结下
wait()
和notify()
/notifyAll()
的特性
@SpringBootApplication public class DemoApplication implements CommandLineRunner { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } @Override public void run(String... args) throws Exception { TaskQueue q = new TaskQueue(); List<Thread> ts = new ArrayList<>(); for (int i = 0; i < 3; i++) { Thread t = new Thread(() -> { try { String s = q.getTask(); } catch (InterruptedException e) { System.out.println(Thread.currentThread().getId() + " is interrupted "); return; } }, "tGet" + i); t.start(); ts.add(t); } Thread add = new Thread(() -> { for (int i = 0; i < 2; i++) { try { Thread.sleep(2000); } catch (InterruptedException e) { } String s = "t-" + Math.random(); System.out.println(LocalTime.now() + " add element: " + s); q.addTask(s); } }, "tAdd"); add.start(); add.join(); Thread.sleep(100); for (Thread t : ts) { t.interrupt(); } System.out.println("main function end"); } public class TaskQueue { Queue<String> queue = new LinkedList<>(); public synchronized void addTask(String s) { this.queue.add(s); this.notifyAll(); } public synchronized String getTask() throws InterruptedException { while (queue.isEmpty()) { this.wait(); } return queue.remove(); } }
- 看下这段代码会怎么执行, 这里要关注调用
wait()
的3个线程状态的变化, 以及this
锁的持有者
- 主线程循环发起3个线程, 调用
getTask()
开始等待,getTask()
方法会调用wait()
, 因此这3个线程会依次获取/释放掉this
锁, 状态Runnable
->Waiting
- 主线程循环发起2个线程, 每隔5s调用一次
addTask()
,addTask()
方法会获取this
锁, 加入1个元素, 以及调用notifyAll()
方法唤醒所有等待的线程, 最终释放锁 - 加入第一个元素, 调用
notifyAll()
方法, 唤醒所有线程, 被唤醒的3个线程开始竞争锁, 它们的状态Waiting
->Blocked
- 拿到锁的线程, 跳出
while
循环remove()
元素返回后,getTask()
方法调用结束,this
锁释放, 该线程的状态Blocked
->Runnable -> Terminated
- 没拿到锁的另外2个线程进入下一次
while
循环 继续等待 它俩的状态Blocked
->Waiting
- 继续加入第二个元素, 状况和上面两步雷同
- 最后, 有3个线程等待获取元素,但我们一共只加入了2个,最终会有1个线程等不到元素还在
Waiting
,他会被主线程interrupt
掉
- 可以加个
sleep()
方便观察到Blocked
状态: stackoverflow.com/q/76748466/… - 可见
wait()
和notify()
/notifyAll()
用法较为繁琐, 稍不注意就会出问题
-
- 常见关键字
sycronized
:
属于可重入锁
:
JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。
由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出
synchronized
块,记录-1,减到0的时候,才会真正释放锁。
用sycronized
修饰的实例方法, 等价于sycronized(this){ ... }
, 对于静态方法则是sycronized(Foo.class){ ... }
, 任何一个类都有一个由JVM自动创建的Class
实例
在使用synchronized
的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized
结束处正确释放锁
volatile
: 在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的
TODO 安全点
TODO 堆栈一致性
因此,volatile
关键字的目的是告诉虚拟机:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
volatile
关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
如果我们去掉volatile
关键字,运行上述程序,发现效果和带volatile
差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。
转载自:https://juejin.cn/post/7258896295616315450