1. 线程的6种状态
操作系统中的进程/线程有五大状态:创建态、运行态、就绪态、阻塞态、终止态。
在Java中对线程的状态进行了更加详细的区分,分别为以下几个状态:
- NEW:创建了Thread对象,还没有调用start方法
- RUNNABLE:就绪状态,这里和系统中就绪状态的又不太一样分为正在CPU运行的和已经准备好上CPU运行的
- BLOCKED:阻塞状态,等待锁(后面详细说)
- WAITING:阻塞状态,线程中调用了
wait()方法(后面详细说) - TIMED_WAITING:阻塞状态,通过
sleep()进入的阻塞 - TERMINATED:系统里的线程已经执行完毕,销毁了,但是Thread对象还在。
图示如下:主干道为 NEW->RUNNABLE->TERMINATED,但是在RUNNABLE阶段可能会经历一些分支(阻塞)
2. 线程安全问题
线程安全问题是多线程编程中最重要,也是最困难的问题,这里演示一个经典的案例。
2.1 引入案例
创建两个线程,让这两个线程对同一个变量自增50000次,最终预期自增100000次
class Count {
public int sum;
public void increase() {
sum++;
}
}
public class Demo12 {
public static void main(String[] args) {
Count count = new Count();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.increase();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(count.sum);
}
}
运行多次结果如下:这其实就是线程安全问题
50161
64987
56213
67145
48267
为什么会出现这种现象呢?
其实像sum++这样一句代码,对应三个机器指令:
- load:把内存中的值读到CPU的寄存器中
- add:在CPU寄存器中,完成加法运算
- save:把寄存器的值存到内存里
它们在多核CPU中并行执行一次load+add+save指令的时候,可能会出现下面的情况:
CPU与内存的初始状态如下:
- 当线程1执行load指令,将内存中的值读到cpu的寄存器中:
- 然后线程2执行load指令,将内存中的值读到cpu的寄存器中:
- 线程1执行add,在cpu中进行+1的操作:
- 线程1执行save,将cpu中的值存入内存中:
- 线程2执行add,在cpu中进行+1的操作:
- 线程2执行save,将cpu中的值存入内存中:
以此类推,在并行执行的情况下,两个线程除了串性执行完3个语句的情况,都会导致其中1个线程的+1操作失效
甚至在并发的情况下,线程1在执行load指令后刚好执行完一个时间片,然后线程2在CPU上连续执行了多次load+add+save的操作,此时线程1继续被调度到CPU上执行add指令和save操作,并且保存到内存中,导致线程2的多次+1的结果被覆盖掉。
2.2 线程不安全的原因
1. 操作系统的抢占性执行/随机调度
2. 多个线程修改同一个变量
3. 修改操作非原子性
原子性的解释:CPU是以一个机器指令为单位来执行的,因此一个机器指令就是一个原子的操作,由于我们前面的案例,一次a++的代码对应了三个机器指令,因此该操作是非原子的。
4. 内存可见性问题
内存可见性的解释:如果是正常情况下,比如线程1循环执行while(a>0)这句代码,该代码涉及LOAD(将变量从内存加载到CPU的寄存器上)+CMP(在CPU中比较寄存器的值)两个机器指令。
在引入优化之前,线程1执行的过程中,线程2突然进行写操作都正常。
因为在线程2写操作结束之后,线程1下一次读的时候就能立刻读到内存的变化。
但是程序在运行的过程中,在多次读到相同的值时可能会涉及到一个“优化”操作,该优化操作可能来自编译器javac、JVM、操作系统。由于LOAD指令涉及读内存的操作(该操作较慢),为了提高性能,JVM在多次读到相同的值后,就直接复用CPU寄存器中的值,也就优化成下面这样:
此时如果线程2突然出现写操作,线程1在下一次读的时候是感知不到的,这就是内存可见性问题。
所谓优化,必须在程序不出BUG的前提下进行,上述场景的优化在单线程中没有问题,但是在多线程的环境下,进行的优化可能就会出现误判。
5. 指令重排序
指令重排序同样也是优化搞的鬼,指令重排序指的是在逻辑不变的情况下,通过指令的重排序,来提高程序运行的速度。
但是在多线程环境下,保证逻辑不变,就不容易了。
比如Test t = new Test()这样的代码,可以分为三步:
- 开辟内存空间
- 在内存中创建对象
- 将对象引用赋值给t
在单线程的环境下,2、3步骤是可以重排序的,假设步骤2和步骤3重排序为了3、2。
在多线程的环境下,线程1执行Test t = new Test()的代码,线程2调用t的方法,线程2使用t对象的时候就可能会出现引用t不为null但是引用指向的对象为null的内存泄漏问题
2.3 线程不安全的解决方案
- 问题一:系统的抢占性执行/随机调度,是我们无能为力的
- 问题二:多个线程同时修改一个变量(部分规避):有些情况就是需要涉及多个线程修改同一个变量。
- 问题三:修改的操作不是原子性的:程序员可通过加锁操作避免
- 问题四:内存可见性问题:
volatile关键字 - 问题五:指令重排序:
volatile关键字
对于上面的线程不安全问题,我们发现问题三到问题五是可以让程序员通过一些操作来避免的,接下来我就详细介绍下加锁操作以及volatile关键字的使用。
2.3.1 解决原子性问题 —— synchronized
加锁的目的就是把一系列机器指令变成原子的,加锁是怎么把这样一个代码块变成原子的呢?
比如当线程A执行到某个需要原子操作的代码块后,如果该代码块没有加锁,会对这个代码块进行加锁。
当线程B再想调用这个代码块的时候,会检查锁的状态,如果此时有锁,那么该线程就会被阻塞,由RUNNABLE状态变为BLOCKED状态,不再参与调度,指导这个锁被释放,该线程才由BLOCKED状态变为RUNNABLE状态,参与操作系统的调度。
这里我将使用synchronized关键字来解决前面提到的案例所引发的线程安全问题。
1)对this进行加锁
使用synchronize修饰代码块的时候,必须传一个锁对象,在Java中的任何一个对象都可以作为锁对象。
public void increase() {
synchronized (this) {
sum++;
}
}
锁对象的解释: 观察上面代码,this其实就是锁对象,加锁操作是针对一个锁对象来进行的。我们可以把这个锁对象想象成一个门,当线程执行到该代码块的时候,如果这个门没有锁,就给这个门上锁。
在这里我们为什么选择使用this充当锁对象呢?
class Count {
public int sum;
public void increase() {
synchronized (this) {
sum++;
}
}
}
观察上面的Count对象,如果想要让多个线程修改同一个变量,他们就必须使用同一个Count实例,像下面这样:
Count count = new Count();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.increase();
}
});
t1.start();
t2.start();
因此t1和t2访问的是同一个门,就只要对这个门上锁就好啦。
这样处理的话,如果现在有一个线程t3还想再对另一个变量进行50000次的自增,就需要再去new一个Count实例。
由于修改的是不同的变量,不存在线程安全问题,因此不需要对t3的自增操作进行加锁,由于此时的this是一个新的门,就算t1或者t2对他们的门进行加锁,t3还是可以进自己的门去执行自增的代码。
public class Demo12 {
public static void main(String[] args) {
Count count1 = new Count();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count1.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count1.increase();
}
});
Count count2 = new Count();
Thread t3 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count2.increase();
}
});
t1.start();
t2.start();
t3.start();
try {
t1.join();
t2.join();
t3.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(count1.sum);
System.out.println(count2.sum);
}
}
同理,我们也可以自定义一个锁对象(必须是成员变量),才能达到一样的效果:
class Count {
private final Object locker = new Object();
public int sum;
public void increase() {
synchronized (locker) {
sum++;
}
}
}
像这样把整个方法中的代码块都进行加锁的情况,相当于直接在普通方法前面添加synchronized关键字来修饰方法:
class Count {
public int sum;
synchronized public void increase() {
sum++;
}
}
2)对类对象加锁
将案例中的代码修改为对静态变量进行自增:
class StaticCount {
public static int sum;
public static void increase() {
sum++;
}
}
public class Demo13 {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
StaticCount.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
StaticCount.increase();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(StaticCount.sum);
}
}
由于静态变量是全局唯一的,因此所有成员锁对象都不适用了,上面的场景就得对类对象进行加锁了:
class StaticCount {
public static int sum;
public static void increase() {
synchronized (StaticCount.class) {
sum++;
}
}
}
像这样把整个方法中的代码块都进行加锁的情况,相当于直接在静态方法前面添加synchronized关键字来修饰方法:
class StaticCount {
public static int sum;
synchronized public static void increase() {
sum++;
}
}
2.3.2 解决优化问题 —— volatile
2.3.2.1 保证内存可见性
这里我将通过代码演示内存可见性问题
创建一个t1线程,如果Counter.flag的值为0,就一直循环运行下去
public class Demo14 {
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag==0) {
}
System.out.println("循环结束!");
});
}
然后在创建一个线程t2,在休眠3s后将Counter.flag的值改为1
Thread t2 = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
counter.flag = 1;
System.out.println("已将Counter.flag的值改为1~");
});
t1.start();
t2.start();
}
}
预期的结果:将Counter.flag改为1后,t1就在下一次读取flag发现不等于0后,就退出循环。
结果是否定的:产生这种BUG的原因就是内存可见性问题!
该问题的解决方式,就是在变量前添加一个volatile关键字:
static class Counter {
volatile public int flag = 0;
}
运行结果:
已将Counter.flag的值改为1~
循环结束!
Process finished with exit code 0
相当于是给这个变量加上了内存屏障(特殊的二进制指令),JVM在读取这个变量的时候,因为内存屏障的存在,就知道要每次都重新读取,而不是草率的优化。
还有个有意思的操作,下面的代码中,我把volatile给删掉,并且在t1线程的循环语句中添加了一个Thread.sleep(100);
public class Demo14 {
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
//创建一个t1线程,如果flag的值为0,就一直循环运行下去
Thread t1 = new Thread(() -> {
while (counter.flag==0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("循环结束!");
});
此时由于在代码中添加了Thread.sleep(100),让CPU读取flag的频率大大降低,就不触发 优化了,也就没有内存可见性问题了:
已将Counter.flag的值改为1~
循环结束!
Process finished with exit code 0
但是由于咱也不好确定啥时候优化,啥时候不优化,因此我们还是在必要时给变量添加volatile关键字。
2.3.2.2 解决指令重排序问题
出了保证内存可见性,volatile修饰的变量还可以避免指令重排序问题。
2.4 线程安全的集合类
在Java标准库中,大部分集合类,都是线程不安全的,如下:
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是还是有一些类是线程安全的,如下:
- Vector(不推荐使用)
- HashTable(不推荐使用)
- ConcurrentHashMap
- StringBuffer
Vector和HashTable把所有关键方法都添加了synchronized,由于加锁后就容易产生阻塞等待,加锁会牺牲很大的运行速度,因此不推荐使用前两种线程安全的集合类。