假设一个这样的场景: 在多线程的代码中, 需要在不同的线程中对同一个变量进行操作. 那此时就会出现问题: 多线程是并发进行的, 也就是说代码运行的时候, 俩个线程会同时对一个变量进行操作, 这样就会涉及到多线程的安全问题:
class Counter{
public int count;
public void add(){
count++;
}
}
public class demo1 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
在这个代码中, 两个线程会分别对count进行自增五千次, 按理说最后打印的结果是一万. 但实际上,多次运行后代码的结果,很难做到一万, 常见于八九千的结果.
其原因在于, add的过程并非不可拆分的, 也就是不具有原子性. 在实际的运行中, add可以大致分为三步: 读取, 加一, 最后再赋值. 当然这并非专业的术语说法, 这里只简单的以此为描述.
由于两个线程同时进行, 也就是都要执行这三步, 且是以抢占式进行执行. 那执行顺序就必然乱套了. 很可能会出现线程1刚将count原值读入, 线程2就将其赋值走了, 根本没来得及加一. 这种还未执行完就将其读入的操作, 也可称其为脏读.
为避免这种乱套的多线程安全问题, 常用办法便是采用加锁(Synchronized), 其用于修饰方法和代码块. 但是特别注意, 加锁是锁的对象. 当某个对象加锁后, 只有当其再解锁后, 另一个线程才能重新获取锁, 否者会陷入阻塞等待的状态:
这样的操作就能保证在执行完一整个add后再执行下一个add. 虽会降低运行速率, 但能保证代码的准确性. 代码上的修改只需将add进行加锁即可保证得到准确的结果:
//只需在此处加锁即可
public synchronized void add(){
count++;
}
}
//或者代码块加锁
public void add(){
synchronized (this) {
count++;
}
}
若两个线程针对不同对象加锁或者一个加锁一个不加锁, 那么也不会存在阻塞等待的情况.
还有一种特殊情况: 多重锁. 即一个线程加了两把锁, 虽然说当一个线程对对象上锁后, 另一个线程是应该阻塞等待的, 但此时若上锁线程就是要访问的线程呢? 这时是否可以考虑开绿灯呢? 这就好比小偷偷不属于自己的东西, 这是不被允许的犯罪行为. 那如果他偷的是自己的东西呢? 这完全是可以的, 因为这压根就不算偷窃.
因此, 对于可以实现多重锁的关键字, 就被认为是可重入的, 反之是不可重入. 在java中的synchronized是属于可重入, 也就是说, 加上述代码合并运行, 仍可以得到正确的结果, 但并非所有的锁都支持该功能:
//可重入
public synchronized void add(){
synchronized (this) {
count++;
}
}
若不支持可重入, 则会陷入死锁状态, 卡在那里 一直阻塞等待.
当然, 死锁的状态并非只有上述的这一种. 第二种是两个线程两把锁, 即两个线程先分别加锁, 然后再尝试获得对方的锁:
public class demo2 {
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (lock1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2){
System.out.println("获取锁2");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock2){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized ((lock1)){
System.out.println("获取锁1");
}
}
});
t1.start();
t2.start();
}
}
在这个代码中就能够看出, 当两个线程将锁1 锁2获取后, 要相互获取对方的锁, 但对方的锁未解锁, 因此在这种情况想两个线程都被阻塞, 不能继续运行. 在这种情况下代码会一直处于运行状态. 可以用jconsole观察到线程是属于阻塞状态.
第三种死锁即第二种死锁的一般情况, 多线程多把锁而非两把锁. 这里涉及到一个经典的吃面问题. 假设, 有一个圆桌, 共坐了五个人, 每两个人之间, 放了一根筷子. 也就是说共放了五根筷子.
假设吃面的人必须得先拿起他左边的筷子, 再拿起他右边的一根筷子. 那在这种情况下考虑极端情况, 当五个人同时都想吃面时, 会同时都拿起左边的筷子, 且右边没有筷子可拿. 这个时候就僵住了, 谁也吃不了面, 谁也不会放下筷子. 同理, 在多线程种, 每个线程就好比每个人, 每跟筷子就好比每个锁, 考虑极端情况, 会出现这种全部僵在一起的状态.
要解决这个问题, 就得先了解死锁的必要条件:\
1. 互斥使用. 线程一上锁, 线程二只能等着.
2. 不可抢占. 线程一获得锁之后, 只能自己释放锁, 而不能由线程二强行获取锁
3.保持稳定性. 若线程一已经获得锁A, 它再尝试获得锁B时, 锁A是不会因为线程一获得锁B而解锁锁A.
4.循环等待. 也就是刚才所演示的. 线程一获得锁A的同时, 线程二获得锁B. 然后线程一要获得锁B, 线程二要获得锁A, 僵持不下.
对于Synchronized而言, 其实必要条件只有第四点. 前三点是无法去改变的. 但对于其他锁来说不一定. 因此, 想要解决死锁, 就只能从, 循环等待入手.
解决方法是, 给每一把锁标号, 再按照标号的一定顺序进行加锁.
以吃面来举例. 将每根筷子标号, 并规定拿筷子必须从小号开始拿. 对应多线程种按锁的标号顺序由小到大加锁. 这样的话, 一号筷子和二号筷子之间的人就拿一号, 二号筷子和三号筷子之间的人就拿二号, 以此类推.
当轮到一号筷子和五号筷子之间的人拿筷子时, 出现问题了. 由于规定按小号拿, 因此应该是拿一号筷子而非五号筷子. 但此时的一号筷子已经被占用. 因此他只能等待, 也就是多线程中的阻塞. 与此同时, 前一个人可以再拿到四号筷子的基础上拿到五号筷子, 也就是获取到锁, 从而执行多线程. 以这种方式, 就不会出现所有人都吃不到面, 避免所有线程都处于阻塞状态. 反应到代码中, 就只需将锁调换一下即可:
public class demo2 {
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
//标号: 锁1 为一号, 锁2 为二号. 由小到大加锁
Thread t1 = new Thread(() -> {
synchronized (lock1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (lock2){
System.out.println("获取锁2");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock1){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized ((lock2)){
System.out.println("获取锁1");
}
}
});
t1.start();
t2.start();
}
}
除此以外, 解决这类问题还可以使用银行家算法. 但是在实际工作中, 使用并不广泛. 因为其过于复杂, 实用性不高.
-------------------------------------------最后编辑于2023.6.1 下午两点左右