进程状态
进程状态:
- 就绪:正在 CPU 上执行,或者随时可以去 CPU 上执行
- 阻塞:暂时不能参与 CPU 的执行
Java 的线程,对应状态做了更详细的区分,不仅仅是就绪和阻塞了
六种状态:
- NEW
- 当前 Thread 对象虽然有了,但是内核的线程还没有(还没调用过 start)
- TERMINATE
- 当前 Thread 对象虽然还在,但是内核的线程已经销毁(线程已经结束)
- RUNNABLE
- 就绪状态,正在 CPU 上运行 + 随时可以去 CPU 上运行
- BLOCKED
- 阻塞状态
- 锁竞争引起的阻塞
- TIMED_WAITING
- 阻塞状态
- 有超时时间的等待,比如 sleep 或者 join 带参数版本
- WAITING
- 阻塞状态
- 没有超时时间
- 通过
jconsole
可以直接看到线程的状态 - 学习线程状态主要是为了调试,比如,遇到某个代码功能没有执行,就可以通过观察对应线程的状态,看是否是因为一些原因阻塞了
线程安全
是什么
罪魁祸首是:线程的调度是随机的
在多个线程同时执行某个代码的时候,可能会引起一些奇怪的 bug。理解了线程安全,才能避免/解决上述的 bug
因为多个线程并发执行引起的 bug,称为“线程安全问题”或者“线程不安全”
java">public class Demo8 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { count++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); }
}
- 此时打印出来的结果不等于 10000,并且每一次都不一样
- 这个写法是 t1 和 t2 并发执行,产生了 bug
若将 join
执行的时机改一下,就不会产生这种 bug
java">public class Demo8 { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { count++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { count++; } }); t1.start(); t1.join(); t2.start(); t2.join(); System.out.println(count); }
}
- 此时打印出来的结果就一直为 10000
- 这个写法本质上相当于 t1 先执行,执行完之后 t2 再执行,t1 和 t2 是串行执行的
为什么
-
上面代码中的
count++
操作,在CPU 看来,是三个指令- 把内存中的数据读取到 CPU 寄存器——
load
- 把 CPU 寄存器里的数据+1——
add
- 把寄存器的值,写回内存——
save
由于不同架构的 CPU,有不同的指令集,不同的指令集里都有不同的指令,针对这三个操作,不同 CPU 里的对应指令名称肯定是不同的
- 把内存中的数据读取到 CPU 寄存器——
-
CPU 在调度执行线程的时候,说不上啥时候就会把线程给切换走(抢占式执行,随机调度),指令是 CPU 最基本的单位,要调度,至少把当前线程执行完,不会执行一般调度走
-
但是
count++
是三个指令,可能会出现 CPU 执行了其中的一个或者两个或者三个指令调度走的情况 -
基于上面的情况,两个线程同时对
count
进行++
,就容易出现 bug
- 只要 t1 和 t2 的三个指令执行不是连在一起的,就都会出现 bug,只有两种情况能正常执行
- 出现 bug 之后,
count
的大小永远小于 10000,但也有可能小于 5000- 因为可能出现
t1++
一次的过程中,t2++
两次,这样得到的结果,正常应该++3 次,而实际只++1 次
综上,原因为:
- 线程在操作系统中,随机调度,抢占式执行
- 多个线程,同时修改同一个变量
- 修改操作不是“原子“的(比如++就是三个指令)
- 内存可见性
- 指令重排序
解决方案
- 上面的第一个原因无法干预,操作系统内核负责的工作,应用层的程序员无法干预
- 第二个原因如果禁止变量修改,也是第一种解决线程安全的思路,但普适性不高,因为是否可行要看实际需求
- 解决线程安全问题,最主要的办法就是把“非原子”的修改,变成“原子”的
加锁
-
此处的加锁并不是真的让
count++
变成原子的,也没有干预线程的调度,只不是通过这种加锁的方式,使一个线程在执行count++
的过程中,其他的线程的count++
不能插队进来 -
把非原子的修改操作,打包成一个整体,变成原子的操作
-
在 Java 中,提供了一个
synchornized
关键字,来完成加锁操作- 这是一个关键字,不是函数,后面的() 并非参数,而是需要指定一个“锁对象”,然后通过“锁对象”来进行后续的判定
()
里的对象可以指定任何对象{}
里面的代码,就是要打包到一起的,()
里面还可以放任意的其他代码,包括调用别的方法啥的,只要是合法的 Java 代码,都是可以放的
java">public class Demo8 { private static int count = 0; private static Object locker = new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { synchronized(locker){ count++; }; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { synchronized (locker){ count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); }
}
- 由于 t1 和 t2 都是针对
locker
对象加锁,t1 先加锁,就加锁成功了,于是 t1 继续执行()
里面的代码(进厕所,执行上厕所的操作) - t2 后加锁的,发现
locker
对象已经被别人先锁了,就只能等(说明现在厕所有人,于是只能排队等待) - 又因为 t1 的
unlock
一定是在save
之后,确保了 t2 执行load
的时候,t1 已经save
- 这两者的++操作不会穿插执行了,也就不会相互覆盖掉对方的结果了
- 这里本质上是将随机的并发执行过程变成了串行执行
-
锁对象肯定是个对象,不能拿 int、
double
这种内置类型写到() 里面,但是其他类型,只要是Object
或者其子类,都可以。例如:一个字符串s = “hello”
,一个链表list
… 都行 -
锁对象作用,就是用来区分多个线程是否针对“同一个对象”加锁
- 是针对同一个对象的话就会出现“阻塞”(锁竞争/锁冲突)
- 不是针对一个对象加锁,就不会出现“阻塞”,两个线程仍然是随机调度的并发执行
- 锁对象,填哪个对象不重要,多个线程是否是同一个锁对象才重要
注意:
- 加锁后的代码,本质上比
join
的串行执行效率是要高很多的 - 加锁只是把线程中的一小部分逻辑变成“串行执行”,剩下的其他部分,仍然是可以并发执行的
java">Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { synchronized(locker){ count++; }; }
}); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { synchronized (locker){ count++; } }
});
- 这里面只是
count++
是串行执行的,而for
循环、比较、i++
都是并发执行的 - 只有锁里面的是串行的,外面的仍然能并发执行
- 引入多线程并发,就是为了提高效率,引入锁之后,相当于一个线程中,小部分工作是不得不串行,但仍有大部分工作是可以并发的。虽然不如整体都并发效率高,但是肯定比整体都串行效率高
如果是三个线程针对同一个对象加锁,也是类似的情况
- 其中某个线程先加上锁,另外两个线程阻塞等待,(哪个线程拿到锁,这个过程是不可预期的)
- 拿到锁的线程释放了锁之后,剩下两个线程谁先拿到锁呢?也是不确定的
- 此处
synchronized
是JVM
提供的功能,synchronized
底层实现就是JVM
中,通过C++
来实现的。进一步的,也是依靠操作系统提供的API
实现的加锁,操作系统的API
则是来自于CPU
上支持的特殊指令来实现的- 系统原生的加锁 API 和很多编程语言的加锁操作的封装方式是两个函数:
lock()
,unlock()
- 像 Java 这种通过
synchronized
关键字,来同时完成加锁/解锁的,比较少见
- 原生的这种做法,最大的问题在于
unlock
可能执行不到,后面排队想用这个锁的就用不了(占着坑不拉屎)- Java 中的
synchronized
是进入代码块就加锁,出了代码块就解锁,无论是return
还是抛出异常,不管以哪种方式出了代码块都会自动解锁,有效避免了没有执行解锁操作的情况
类对象:
- 一个 Java 进程中,一个类的对象是只有唯一一个的
- 类对象也是对象,也能写到
synchronized()
里面 - 写类对象和写其他对象没有任何本质区别
- 换句话说,写成类对象,就是偷懒的做法,不想创建单独的锁对象了,就可以拿类对象来客串一下
java">Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { synchronized(Demo8.class){ count++; } }
});
synchronized()
还可以修饰一个方法
java">class Counter { public int count = 0; synchronized public void add() { count++; }//等价于public void add() {synchronized (this) {count++;}}
}
- 针对这个写法,锁对象就是
this
,谁调用add
谁就是锁对象- 在这里调用
add
的都是counter
,所以他们是同一个锁对象
java">synchronized public static void func() {//错
}//对
public static void func() {synchronized (Counter.class)
}
static
方法没有this
static
方法也叫类方法,和具体的实例无关,只是和类相关- 此时
static
方法和类对象相关,此时的写法就是给类对象加锁
- 并非是写了
synchronized
就一定线程安全,还是得看代码咋写 - 锁是需要的时候才使用,不需要的时候不要使用,用锁是会付出代价的(性能代价)
- 使用锁,就可能触发阻塞,一旦某个线程阻塞,啥时候能恢复阻塞,继续执行,是不可预期的(可能需要非常多的时间)
synchronized
的几种使用方式:
synchronized(){}
,() 里面指定锁对象synchronized
修饰一个普通的方法,相当于针对this
加锁synchronized
修饰一个静态方法,相当于针对对应的类对象加锁
理解锁对象的作用,可以把任意的 Object/Object
子类的对象作为锁对象。锁对象是啥不重要,重要的是两个线程的锁对象是否是同一个。是同一个才会出现阻塞/锁竞争,不是同一个是不会出现的
死锁
使用锁的过程中,一种典型的、严重的 bug
一、一个线程针对一把锁,连续加锁两次
java">class Counter { public int count = 0; public void add() { //随后调用add方法,又尝试对counter加锁 //但counter已经被加锁了,如果再次尝试对counter加锁,就会出现阻塞等待 synchronized (this){ count++; } }
} public class Demo9 { public static void main(String[] args) throws InterruptedException { Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 5000; i++) { //首先执行到这里,对counter加锁成功 synchronized (counter){ counter.add(); } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 5000; i++) { synchronized (counter){ counter.add(); } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.count); }
}
- 里面的
synchronized
想要拿到锁,就需要外面的synchronized
释放锁 - 外面的
synchronized
要释放锁,就需要执行到}
- 要想执行到
}
,就需要执行完这里的add
- 但是
add
阻塞着呢~
- 但是上面的运行结果霉运造成死锁,是因为 Java 为了减少程序员写出死锁的概率,引入了特殊机制,解决上述的死锁问题——“可重入锁”
可重入锁:
synchronized
是“可重入锁”,针对上述一个线程连续加锁两次的情况做了特殊处理。C++/Python 中的锁就没有这样的功能
- 加锁的时候,需要判定当前这个锁是否是已经被占用的状态
- 可重入锁就是在所中额外记录一下,当前是哪个线程对这个锁加锁了
- 对于可重入锁来说,发现加锁的线程就是当前锁的持有线程,并不会真正进行任何加锁操作,也不会进行任何“阻塞操作”,而是直接放行,往下执行代码
- 比如你向一个女生表白了,她同意了,那你就对她加锁了,她的持有人就是你
- 再有人对她加锁的时候,她就会进行判定,看当前这个要加锁的人,是不是她的持有人
- 若是隔壁老王找到她说“美女,我好喜欢你”,那她就会告诉他“你是个好人”
- 若是你找到她说“我好喜欢你”,那她就会说“我也好喜欢你”
java">//真加锁
synchronized (this) {//直接放行,不会真加锁synchronized (this){}
}
二、两个线程两把锁
- 若有两个线程 1 和 2,两把锁 A 和 B
- 线程 1 先针对 A 加锁,线程 2 针对 B 加锁
- 线程 1 不释放锁 A 的情况下,再针对 2 加锁;同时,线程 2 在不释放 B 的情况下针对 A 加锁
- 比如你和你女朋友出去吃饺子,她拿的醋,你拿的酱油
- 你说:你把醋给我,我用完了给你
- 她说:凭什么,你把酱油给我,我用完了给你
- 结果是你俩互不相让,僵持住了
- 死锁往往是出现了“循环依赖”
- 这种死锁情况,可重入锁机制也无能为力
java">public class Demo1 { private static Object locker1 = new Object(); private static Object locker2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (locker1){ System.out.println("t1 加锁 locker1 完成"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2){ System.out.println("t1 加锁 locker2 完成"); } } }); Thread t2 = new Thread(() -> { synchronized (locker2) { System.out.println("t2 加锁 locker2 完成"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker1){ System.out.println("t2 加锁 locker1 完成"); } } }); t1.start(); t2.start(); }
}
打印结果:
t1 加锁 locker1 完成
t2 加锁 locker2 完成
- 代码中的
sleep
是为了确保 t1 和 t2 都先分别拿到了locker1
和locker2
,然后再分别拿对方的锁。如果没有sleep
,执行顺序就不可控,可能出现某个线程一口气拿到两把锁,另一个线程还没开始执行的情况,无法构造出死锁- 两个线程的第二次交叉加锁都没执行到,说明这两个线程都在第二次 synchronized 的时候阻塞住了,如果不人为干预,就会永远堵在这
三、有 N 个线程,M 个锁
经典模型:哲学家就餐问题:
死锁的四个必要条件
死锁的四个必要条件:(缺一不可)
- 锁是互斥的[锁的基本特性]
- 基本特性,无法干预
- 锁是不可抢占的。线程 1 拿到了锁 A,如果线程 1 不主动释放 A,线程 2 就不能把 A 抢过来[锁的基本特性]
- 基本特性,无法干预
- 请求和保持。线程 1 拿到锁 A 之后,在不释放 A 的前提下,去拿锁 B[代码结构]
- 如何避免:
- 先释放 A,再拿 B
- 避免锁嵌套锁
- 循环等待/环路等待/循环依赖
- 如何避免:
- 一个简单有效的方法:给锁编号,1、2、3、4、…、N
约定所有的线程在加锁的时候,都不许按照一定的顺序来加锁(比如,必须针对编号小的锁进行加锁,后针对编号大的锁进行加锁) - 约定加锁顺序
java">public class Demo1 { private static Object locker1 = new Object(); private static Object locker2 = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (locker1){ System.out.println("t1 加锁 locker1 完成"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2){ System.out.println("t1 加锁 locker2 完成"); } } }); Thread t2 = new Thread(() -> { synchronized (locker1) { System.out.println("t2 加锁 locker1 完成"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (locker2){ System.out.println("t2 加锁 locker2 完成"); } } }); t1.start(); t2.start(); }
}
输出结果:
t1 加锁 locker1 完成
t1 加锁 locker2 完成
t2 加锁 locker1 完成
t2 加锁 locker2 完成
- 将上锁的顺序都改为先上
locker1
,再上locker2
就能解决这里的死锁问题了