死锁、活锁与饥饿
-
starvation
:饥饿是指某一个或多个线程因为某种原因无法获得所需要的资源,导致一直无法执行。 -
deadlock
:是一种静态现象,指两个或两个以上的进程(或线程)在执行的过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,他们将无法推进下去。 -
livelock
:是一种动态现象,任务执行者没有被阻塞,由于某些条件一直没有被满足,导致一直重复尝试,失败、尝试、失败。在这期间,线程状态会不停的改变。例子:电梯遇人
死锁&活锁
死锁会阻塞,一直等待对方释放资源,一直处在阻塞状态;活锁会不停的改变线程状态尝试获得资源。
活锁有可能自行解开,死锁则不行。
死锁发生的条件:
4. 互斥条件:线程对资源的访问是排他性的,如果一个线程占有了某个资源,那么其他线程必须处于等待状态,直到资源被释放。
5. 请求和保持条件:线程T1
至少已经保持了一个资源R1
占用,但又提出了对另一个资源R2
请求,而此时,资源R2
被其他线程T2
占用,于是该线程T1
也必须等待,但又对自己保持的资源R1
不释放。
6. 不剥夺条件:线程已经获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完之后自己释放。
7. 环路等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
只要系统发生了死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
并发级别
阻塞
在临界区进行等待,等待会导致线程挂起。
非阻塞
非阻塞允许多个线程同时进入临界区。
- 无障碍:是一种最弱的非阻塞调度;自由进入临界区(宽进严出);无竞争时,有限步内完成操作;有竞争时,回滚数据。
- 无锁:是无障碍的,保证有一个进程可以胜出。
- 无等待:并行级别最高。无锁的;要求所有的线程都是在有限步内完成;无饥饿的。例如:只有读线程。
两个并行定律
Amdaul
定律(阿姆达尔定律)
定义了加速比:加速比=优化前系统耗时/优化后系统耗时
增加CPU处理器的数量并不一定能够起到有效的作用提高系统内可并行化的模块比重,合理增加并行处理器数量,才会以最小的投入得到最大的加速比。Gustafson
定律(古斯塔夫森定律)
只要足够的并行化,加速比和CPU个数呈正比。
共享性
数据共享性是线程安全的主要原因之一。如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。但是,在多线程编程中,数据共享是不可避免的,最典型的是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中的数据,即使是在主从的情况下,访问的也是同一份数据,主从只是为了访问的效率和数据安全,而对同一份数据做的副本,以下是多线程下共享数据导致的问题:
public class ShareData {
public static int count = 0;
public static void main(String[] args) {
final ShareData data = new ShareData();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
//进入的时候暂停1毫秒,增加并发问题出现的几率
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 100; j++) {
data.addCount();
}
System.out.print(count + " ");
}
}).start();
}
try {
//主程序暂停3秒,以保证上面的程序执行完成
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count=" + count);
}
public void addCount() {
count++;
}
}
上述代码的目的是对count
进行加一操作,执行1000次,不过是通过10个线程来实现的,每个线程执行100次,正常情况下,应该输出1000。但实际运行上面的程序,结果却不是这样,每次运行的结果不一样,有时候会取得正确的结果。
可以看出,对共享变量的操作,在多线程的环境下很容易出现各种意想不到的结果。
互斥性
资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排他性。我们通常允许多个线程同时对数据进行读操作,但同一时间只允许一个线程对数据进行写操作。我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。
如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全问题。上述例子就是因为没有保证互斥性才导致数据的修改产生问题。Java中提供多种机制来保证互斥性,最简单的就是使用Synchronized
,对上述程序进行修改,加上Synchronized
:
public class ShareData {
public static int count = 0;
public static void main(String[] args) {
final ShareData data = new ShareData();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
//进入的时候暂停1毫秒,增加并发问题出现的几率
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 100; j++) {
data.addCount();
}
System.out.print(count + " ");
}
}).start();
}
try {
//主程序暂停3秒,以保证上面的程序执行完成
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count=" + count);
}
/**
* 增加 synchronized 关键字
*/
public synchronized void addCount() {
count++;
}
}
再执行上述代码,会发现无论执行多少次,最终结果都是正确返回1000。
原子性
原子性是指对数据的操作是一个独立的、不可分割的整体。也就是说一次操作,是一个不可中断的过程,数据不会执行到一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,如果一次操作对应一条操作系统指令,这样就可以保证原子性,但是很多操作不能通过一条指令就完成。例如:对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成;还有我们经常使用的i++
的操作,其实需要分成三个步骤:(1)读取整数i
的值;(2)对i
进行加一操作(3)将结果写会内存。这个过程在多线程下就可能出现如下现象:
这也是上述代码执行不争取的原因,对于这种操作,要保证原子性,最常见的方式就是加锁,像java中的synchronized
或lock
都可以实现,除了锁以外,还有一种方式就是CAS
,即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。但是CAS
在某些场景下不一定有效,比如另一个线程先修改了某个值,然后再改回原来值,这种情况下,CAS
是无法判断的。
可见性
要理解可见性,需要对JVM的内存模型有一定的了解,JVM的内存模型与操作系统类似:
每个线程都有一个自己的工作内存(相当于CPU高级缓冲,这么做的目的还是在于进一步缩小存储系统与CPU之间的速度的差异,提高性能),对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是:如果某个线程1对某个变量进行了修改,线程2却有可能看不到线程1对共享变量所做的修改。相关代码如下:
public class VisibilityTest {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (!ready) {
System.out.println(ready);
}
System.out.println(number);
}
}
private static class WriterThread extends Thread {
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
number = 100;
ready = true;
}
}
public static void main(String[] args) {
new WriterThread().start();
new ReaderThread().start();
}
}
直观上理解:应该输出100,不会打印ready
的值,实际上,如果多次执行上面代码的话,可能会出现多种不同的结果,以下是运行出来某两次的结果:
出现这种现象有可能是由于可见性造成的,当写线程设置ready=true
后,读线程看不到修改后的结果,所以会打印false
,对于第二个结果,也就是执行if(!ready)
时还没有读取到写线程的结果,但执行System.out.println(ready)
时读取到了写线程执行的结果。不过,这个结果也有可能是线程的交替执行所造成的,java中可通过Synchronized
或Volatile
来保证可见性。
有序性
为了提高性能,编译器和处理器可能会对指令做重排序,重排序可以分为三种:
(1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可能会重新安排语句的执行顺序。
(2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
(3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。