一、线程的状态
1.1 NEW
NEW
: 安排了工作, 还未开始行动;
把Thread
对象创建好了,但是还没有调用start
。
public class Test01 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true){
}
});
System.out.println(t.getState());//获取线程t的状态 1.
t.start();
}
}
执行结果:
1.2 RUNNABLE
RUNNABLE
: 可工作的. 又可以分成正在工作中和即将开始工作.;
public class Test01 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true){
// 3.runnable
//这里啥也不能有
}
});
t.start();
Thread.sleep(1000);// 2.
System.out.println(t.getState());//获取线程t的状态
}
}
执行结果:
就绪状态,处于这个状态的线程,就是在就绪队列中.随时可以被调度到CPU上,
如果代码中没有进行sleep
,也没有进行其他的可能导致阻塞的操作,代码大概率是处在Runnable
状态的 。
1.3 BLOCKED
BLOCKED
: 这几个都表示排队等着其他事情;
当前线程在等待锁,导致了阻塞(阻塞状态之一)synchronized
1.4 WAITING
WAITING
: 这几个都表示排队等着其他事情;
当前线程在等待唤醒,导致了阻塞(阻塞状态之一)wait
1.5 TIMED_WAITING
TIMED_WAITING
: 这几个都表示排队等着其他事情;
代码中调用了sleep
,就会进入到TIMED_WAITING
,join(超时时间)
意思就是当前的线程在一定时间之内是阻塞的状态。
public class Test01 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while (true){
try {
//代码中调用了`sleep`
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
Thread.sleep(1000);// 2.
System.out.println(t.getState());//获取线程t的状态
}
}
输出结果:
1.6 TERMINATED
TERMINATED
: 工作完成了。
操作系统中的线程已经执行完毕,销毁了.但是 Thread对象还在,获取到的状态.
public class Test01 {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
});
t.start();
Thread.sleep(1000);// 2.
System.out.println(t.getState());//获取线程t的状态
}
}
输出结果:
这个细化的原因是:在开发过程中经常会遇到一种情况,程序“卡死”了,一些关键的线程阻塞了,在分析卡死原因的时候,第一步就可以先来看看当前程序里的各种关键线程所处的状态。
线程状态转换图:
二、多线程带来的的风险-线程安全
2.1 线程安全的概念
操作系统调度线程的时候,是随机的(抢占式执行),正是因为这样的随机性,就可能导致程序的执行出现一些bug。
如果因为这样的调度随机性引入了bug,就认为代码是线程不安全的。
如果是因为这样的调度随机性,没有带来bug,就认为代码是线程安全的。
2.2 线程不安全的典型案例
使用两个线程,对同一个整型变量,进行自增操作,每个线程自增5w
次,查看最终的结果。
class Counter{
public int count;
//加锁之后,就变成线程安全的了
// synchronized public void increase(){
public void increase(){
count++;
}
}
public class Test02 {
private static Counter counter = new Counter();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
//这俩个join 谁在前,谁在后,都没关系
//由于线程调度是随机的.咱们也不知道t1先结束,还是t2先结束
t1.join();
t2.join();
System.out.println(counter.count);
}
}
输出结果:
这5w对并发相加中,有时候可能是串行的(+2),有的时候是交错的(+1),具体串行的有多少次,交错的有多少次,都是随机的。
极端情况下:
如果所有的操作都是串行的, 此时结果就是10w(可能出现的,但是小概率事件)
如果所有的操作都是交错的,此时结果就是5w(可能出现的,也是小概率事件)
对于t1.join();
和 t2.join();
:
假设t1先结束:先执行t1.join,然后等待t1结束,t1结束了;接下来调动t2.join,等待t2结束,t2结束了,t2.join执行完毕。
假设t2先结束:先执行t1. join,等到t1结束.
t2结束了,t1还没结束.main 线程仍然阻塞在t1.join中.再过一会, t1结束了, t1.join返回,
执行t2.join (此时由于t2已经结束了),t2.join就会立即返回。
count++到底干了什么?
站在CPU的角度来看待,count++
实际上是三个CPU 指令!!!
因为操作系统调度线程的时候"抢占式执行",这就导致两个线程同时执行这三个指令的时候,顺序上充满了随机性。
另一种情况:
在"抢占式执行”的情况下, t1
和t2
的这三个指令之间的相对顺序是充满随机性,上述情况都可能发生.并且哪种情况出现多少次,是否出现,都无法预测!
2.3 (重点)线程不安全的原因
- 线程是抢占式执行,线程间的调度充满随机性.[线程不安全的万恶之源!!]
- 多个线程对同一个变量进行修改操作。(如果是多个线程针对不同的变量进行修改,没事!如果多个线程针对同一个变量读,也没事!)可以通过调整代码结构,使不同线程操作不同变量。
- 针对变量的操作不是原子的~(讲数据库事务的时候也讲到过原子性)此处说的操作原子性也是类似
针对有些操作,比如读取变量的值,只是对应一条机器指令,此时这样的操作本身就可以视为是原子的。通过加锁操作,也就是把好几个指令给打包成一个原子的了。加锁操作,就是把这里的多个操作打包成一个原子的操作。 - 内存可见性,也会影响到线程安全。
例:针对同一个变量,一个线程进行读操作(循环进行很多次),一个线程进行修改操作(合适的时候执行一次)。
t1这个线程,在循环读这个变量.按照之前的介绍,读取内存操作,相比于读取寄存器,是一个非常低效的操作(慢3-4个数量级),因此在t1中频繁的读取这里的内存的值,就会非常低效,而且如果t2线程迟迟不修改, t1线程读到的值又始终是一样的值!!因此, t1就有了一个大胆的想法!!!就会不再从内存读数据了,而是直接从寄存器里读(不执行load 了),一旦t1做出了这种大胆的假设,此时万一t2修改了count 值, t1就不能感知到了。
//线程不安全原因---内存可见性
public class Test03 {
//加上volatile保证了内存的可见性,但其不能保证原子性
public static volatile int isQuite = 0;
// public static int isQuite = 0;
public static void main(String[] args) {
Thread t = new Thread(()->{
while (isQuite == 0){
// try {
// //在循环中加入sleep,这里的优化就消失了。
// Thread.sleep(1000);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
}
System.out.println("循环结束 t线程退出!");
});
t.start();
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个数:");
isQuite = scanner.nextInt();
System.out.println("main 线程执行完毕");
}
}
不加锁的执行结果:程序一直在循环当中
加上volatile
之后的执行结果:
内存可见性,是属于编译器优化范围中的一个典型案例,编译器优化本身是一个玄学的问题.对于普通程序猿来说,啥时候优化,啥时候不优化,很难说。
像上述代码中:循环中加上sleep,这里的优化就消失,也就没有内存可见性问题了。
- 使用
synchronized
关键字
(synchronized
不光能保证指令的原子性,同时也能保证内存可见性同时还能禁止指令重排序)被synchronized
包裹起来的代码,编译器就不敢轻易的做出上述假设,相当于手动禁用了编译器的优化。
- 使用
volatile
关键字
volatile
和原子性无关,但是能够保证内存可见性.禁止编译器做出上述优化.编译器每次执行判定相等,都会重新从内存读取 isQuit
的值!
5.指令重排序,也会影响到线程安全问题。
指令重排序,也是编译器优化中的一种操作,咱们写的很多代码,彼此的顺序,谁在前
谁在后无所谓,编译器就会智能的调整这里代码的前后顺序从而提高程序的效率。
保证逻辑不变的前提,再去调整顺序,如果代码是单线程的程序,编译器的判定一般都是很准;但是如果代码是多线程的,编译器也可能产生误判。
三、synchronized 关键字-监视器锁monitor lock
同步的:同步这个词,在计算机中是存在多种意思,不同的上下文中,会有不同的含义。
比如,在多线程中,线程安全中,同步其实指的是"互斥"
比如在IO或者网络编程中,同步相对的词叫做“异步’,此处的同步和互斥没有任何关系。和线程也没有关系了,表示的是消息的发送方,如何获取到结果。
3.1 synchronized的使用方式
- 直接修饰普通的方法.
使用synchronized
的时候,本质上是在针对某个"对象"进行加锁,此时锁对象就是this
。
public class SynchronizedDemo {
//就是针对this来加锁,加锁操作就是在设置this的对象头的标志位.
public synchronized void methond() {
}
}
一个对象,在Java中,每个类都是继承自Object,每个new出来的实例,里面一方面包含了你自己安排的属性,一方面包含了“对象头",对象的一些元数据。
如:
2. 修饰一个代码块
需要显式指定针对哪个对象加锁. (Java中的任意对象都可以作为锁对象)
这种随手拿个对象都能作为锁对象的用法,这是Java中非常有特色的设定.(别的语言都不是这么搞.正常的语言都是有专门的锁对象)
锁当前对象:
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
锁类对象:
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
- 修饰一个静态方法.
相当于针对当前类的类对象加锁.
类对象,就是咱们在运行程序的时候,.class
文件被加载到JVM
内存中的模样 .
反射机制,都是来自于.class
赋予的力量 .Counter.class
(反射)
public class SynchronizedDemo {
public synchronized static void method() {
}
//
public static void method() {
synchronized (Counter.class){
}
}
}
3.2 synchronized的特性
互斥
synchronized
会起到互斥效果, 某个线程执行到某个对象的 synchronized
中时, 其他线程如果也执行到同一个对象 synchronized
就会阻塞等待.
进入 synchronized
修饰的代码块, 相当于 加锁;
退出 synchronized
修饰的代码块, 相当于 解锁;
阻塞等待:针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁.
注意:
上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的一部分工作.
假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则。
刷新内存
synchronized
的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
所以synchronized
也能保证内存可见性.
可重入
同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入.如果不会死锁,就是可重入。
synchronized
实现了可重入锁,对于可重入锁来说,连续加锁,不会导致死锁。
可重入锁的意义就是降低了程序猿的负担.(使用成本,提高了开发效率),但是也带来了代价:程序中需要有更高的开销(维护锁属于哪个线程,并且加减计数.降低了运行效率)。
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
- 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到).
死锁的其他场景
1.一个线程,一把锁
2.两个线程,两把锁
3.N个线程,M把锁
例:哲学家就餐问题
每个哲学家,会做两件事:1.思考人生,2.吃面条
每个哲学家啥时候思考人生,啥时候吃面条,是不确定(随机的)
每个哲学家吃面条的时候,都需要拿起他身边的两根筷子(假设先拿起左手的,后拿起右手的),每个哲学家都是非常固执的,如果想吃面条的时候,尝试拿筷子发现筷子被别人占用着,就会一直等!!!
在这个模型中,如果五个哲学家,同时伸出左手,拿起左手的筷子此时,就死锁了。
约定,让哲学家拿筷子,不是先拿左手,后拿右手了,而是先拿编号小的,后拿编号大的。
死锁的四个必要条件
1.互斥使用:一个锁被一个线程占用了之后,其他线程占用不了(锁的本质,保证原子性)
2.不可抢占:一个锁被一个线程占用了之后,其他的线程不能把这个锁给抢走.
3.请求和保持:当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁始终都是被该线程持有。
4.环路等待:等待关系,成环了(A等B,B等C, C又等A)。
如何避免出现环路等待?
只要约定好,针对多把锁加锁时候,有固定的顺序即可。所有的线程都遵守同样的规则顺序,就不会出现环路等待。
Java 标准库中的线程安全类
Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是还有一些是线程安全的. 使用了一些锁机制来控制.
- Vector(不推荐使用)
- HashTable (不推荐使用)
- ConcurrentHashMap
- StringBuffer
还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的
- String
String方法没有synchronized,String是不可变对象,无法在多个线程中同时改同一个 String (单线程中都没法改String)。
四、 volatile 关键字
volatile 能保证内存可见性
禁止编译器优化,保证内存可见性
计算机要想执行一些计算,就需要把内存的数据读到CPU寄存器中,然后再在寄存器中计算,再写回到内存中。但CPU访问寄存器的速度,比访问内存快太多了.当CPU连续多次访问内存,发现结果都一样,CPU就想偷懒。
JMM:Java Memory Model
(Java内存模型)
JMM
就是把上述讲的硬件结构,在Java中用专门的术语又重新抽象封装了一遍。
一方面,是因为Java 作为一个跨平台的编程语言,要把硬件的细节封装起来(期望程序猿感知不到CPU,内存等硬件设备)。假设某个计算机没有CPU,或者没有内存,同样可以套在上述的模型中。
CPU 从内存取数据,取的太慢了,尤其是频繁取的时候,就可以把这样的数据直接放到寄存器里,后面直接从寄存器来读(寄存器,空间太紧张),于是CPU又另外搞了一个存储空间,这个空间,比寄存器大,比内存小.速度比寄存器慢,比内存块,称为缓存(cache),一般常见的都有三层缓存。
volatile 不保证原子性
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.
3.4 wait 和 notify
是处理线程调度随机性的问题的.有时候不喜欢随机性,需要让彼此之间有一个固定的顺序。join
也是一种控制顺序的方式,但更倾向于控制线程结束。
wait
和notify
都是Object
对象的方法。
调用wait
方法的线程,就会陷入阻塞.阻塞到有其他线程通过notify
来通知。
public class Test05 {
private static Object locker = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
synchronized (locker){
System.out.println("wait 前");
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 后");
}
});
t1.start();
Thread.sleep(3000);
Thread t2 = new Thread(()->{
synchronized (locker){
System.out.println("notify 前");
locker.notify();
System.out.println("notify 后");
}
});
t2.start();
}
}
执行结果:
wait内部会做三件事:
- 先释放锁
- 等待其他线程的通知.
- 收到通知之后,重新获取锁,并继续往下执行。
因此要想使用wait / notify
,就得搭配synchronized
;
wait和notify
都是针对同一个对象来操作的.
例如现在有一个对象O
:
有10个线程,都调用了o.wait
.此时10个线程都是阻塞状态.
如果调用了o.notify
,就会把10个其中的一个给唤醒.(唤醒哪个是不确定的)
针对o.notifyAll
,就会把所有的10个线程都给唤醒 ,wait
唤醒之后,会重新尝试获取到锁 (这个过程就会发生竞争)。相对来说,更常用的还是notify
。
wait 和 sleep 的对比
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间.
wait
需要搭配synchronized
使用.sleep
不需要.wait
是Object
的方法,sleep
是Thread
的静态方法.