转载请标明出处,https://www.jianshu.com/u/64f43a5add47。
我们现在的操作系统的硬件架构模型:内存、多线程、OS调度、多核心CPU
在每一个核心里面,它是可以运行一个线程的,所以在多核心里面我们就可以同一时刻运行多个线程,达到真正意义上的并行。
线程作为操作系统的最小单元,并且能够让多线程同时执行,极大的提高了程序的性能,在多核环境下的优势更加明显,但是在使用多线程的过程中,如果对它的特性和原理不够理解的话,很容易造成各种问题,Java线程既然能够创建,那么也势必会被销毁,所以线程是存在生命周期的,那么我们接下来从线程的生命周期开始去了解线程。
java线程一共有6种状态
NEW:初始状态:线程被构建,但是还没有调用start方法
RUNNABLED:运行状态,JAVA线程把就绪和运行两种状态统一称为“运行中”(线程在启动的时候不是立马运行的,而是要通过os调度才会去执行)
BLOCKED:阻塞状态,表示线程进入等待状态,也就是线程因为某种原因放弃了CPU使用权,阻塞也分为几种情况
等待阻塞:运行的线程执行wait方法,jvm会把当前线程放入到等待队列。
同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被其他线程锁占用了,那么jvm会把当前的线程放入到锁池中。
其他阻塞:运行的线程执行Thread.sleep或者t.join方法,或者发出了I/O请求时,JVM会把当前线程设置为阻塞状态,当sleep结束、join线程终止、io处理完毕则线程恢复。
TIME_WAITING:超时等待状态,超时以后自动返回
TERMINATED:终止状态,表示当前线程执行完毕
public class ThreadStatusDemo {
public static void main(String[] args) {
new Thread(()->{
while(true){
try {
TimeUnit.SECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"timewaiting").start();
new Thread(()->{
while(true){
synchronized (ThreadStatusDemo.class){
try {
ThreadStatusDemo.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
},"waiting").start();
new Thread(new BlockDemo(),"BlockDemo-0").start();
new Thread(new BlockDemo(),"BlockDemo-1").start();
}
static class BlockDemo extends Thread{
public void run(){
synchronized (BlockDemo.class){
while(true){
try {
TimeUnit.SECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
打开终端或者命令提示符,键入“jps”,(JDK1.5提供的一个显示当前所有java进程pid的命令),可以获得相应进程的pid
根据上一步获得的pid,继续输入jstack pid(jstack是java虚拟机自带的一种堆栈跟踪工具。jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息)
线程的启动和终止
启动线程我们使用start(这是一个native方法)
记住,线程的终止,并不是简单的调用stop命令去。虽然api仍然可以调用,但是和其他的线程控制方法如suspend、resume一样都是过期了的不建议使用,就拿stop来说,stop方法在结束一个线程时并不会保证线程的资源正常释放,我们不知道当前进程还有没有用户请求和有没有请求是还没有处理完的,如果没有处理完就强制关闭那这样就会导致我们的不可预测的问题出现。
要优雅的去中断一个线程,在线程中提供了一个interrupt方法;还有一种就是通过指令的方式,比如定义一个这样的成员变量volatile boolean isStop = false; 然后我们在其他的线程里面去修改这个值的时候,因为volatile可以保证可见性,那么我们可以在其他线程看到这个变化以后我们可以去让它停止,这种也是非常常用的,不会容易遇到一些坑的问题。
interrupt方法
当其他线程通过调用当前线程的interrupt方法,表示向当前线程打个招呼,告诉他可以中断线程的执行了,至于什么时候中断,取决于当前线程自己。
线程通过检查自身是否被中断来进行相应,可以通过isInterrupted()来判断是否被中断。通过下面这个例子,来实现了线程终止的逻辑
public class InterruptDemo {
private static int i;
public static void main(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
// 当设置了interrupt标识为true,这时候就不运行了
while(!Thread.currentThread().isInterrupted()){
i++;
}
System.out.println(i);
},"interruptDemo");
thread.start();
TimeUnit.SECONDS.sleep(1);
//设置interrupt标识为true(提供一个钩子)
thread.interrupt();
System.out.println(thread.isInterrupted());
}
}
这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程停止,因此这种终止线程的做法显得更加安全和优雅。
Thread.interrupted
上面的案例中,通过interrupt,设置了一个标识告诉线程可以终止了,线程中还提供了静态方法Thread.interrupted()对设置中断标识的线程复位。比如在上面的案例中,外面的线程调用thread.interrupt来设置中断标识,而在线程里面,又通过Thread.interrupted把线程的标识又进行了复位
public class ThreadInterruptDemo {
public static void main(String[] args) throws InterruptedException {
Thread thred=new Thread(()->{
while(true){
boolean in=Thread.currentThread().isInterrupted();
if(in){
// 如果线程被中断过的话 in 会变成true
System.out.println("before:"+in);
//设置复位,就变成 false
Thread.interrupted();
System.out.println("after:"+Thread.currentThread().isInterrupted());
}
}
});
thred.start();
TimeUnit.SECONDS.sleep(1);
// 中断
thred.interrupt();
}
}
其他的线程复位
除了通过Thread.interrupted方法对线程中断标识进行复位以外,还有一种被动复位的场景,就是对抛出InterruptedException异常的方法,在InterruptedException抛出之前,JVM会先把线程的中断标识位清除,然后才会抛出InterruptedException,这个时候如果调用isInterrupted方法,将会返回false
public static void main1(String[] args) throws InterruptedException {
Thread thread=new Thread(()->{
while(true){
// 抛异常会复位
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
TimeUnit.SECONDS.sleep(1);
// 设置复位表示为true
thread.interrupt();
TimeUnit.SECONDS.sleep(1);
// 输出false
System.out.println(thread.isInterrupted());
}
线程复位可以用来实现多个线程之间的通信。
除了通过interrupt标识为去中断线程以外,我们还可以通过下面这种方式,定义一个volatile修饰的成员变量,来控制线程的终止。这实际上是应用了volatile能够实现多线程之间共享变量的可见性这一特点来实现的。
/**
* 可见性问题
*/
public class VisableDemo {
// 所以解决方法加一个volatile关键字,volatile涉及一个内存屏障的概念
private volatile static boolean stop = false;
/**
* 这是典型的可见性问题,通过主线程设置stop = true,但是子线程一般都拿不到,
* 所以子线程一般都会挂死
*/
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
System.out.println(i);
}
System.out.println(Thread.currentThread().getName());
},"厉害");
thread.start();
TimeUnit.SECONDS.sleep(1);
stop = true;
}
}
线程安全问题
线程会存在安全性问题,从底层去分析线程安全问题,其实线程安全问题总共分成三个:可见性、原子性、有序性问题
认识这三个问题:1、从原理层面即操作系统层面去看这三个问题产生的原因;2、从应用层即从JVM应用层面去看怎么去解决这些问题
可见性:线程1设置变量的值,线程2获取不到(还是旧值)
原子性:多线程运算 1000次for循环的++n,输出结果小于等于预期值,这就不是原子递增了
有序性:是指程序运行中,代码执行的顺序跟我们编写代码的顺序可能不一致,为什么?因为它会存在编译器的优化和指令的优化,这个指令的优化实际上是在CPU运行过程中的优化,就是一个乱序执行的过程,它会优化以后提升我们CPU的执行效率,所以java内存模型里面允许编译器和处理器去进行指令重排,去优化我们的一个执行的过程去提高我们CPU的利用率,在保证不影响我们代码的语义下,它会做一下适当的重排序。
CPU的高速缓存
线程是CPU最小的调度单元,但是CPU调度线程去执行一些指令的时候,它不仅仅只靠计算机的处理器去完成的,处理器还要跟内存去交互的,执行一个指令的时候可能要到内存中加载一些数据,比如说读取、存储,那这个地方就会存在一个IO操作,因为CPU的执行效率要比内存的运算速度高得多,所以为了解决CPU里面的运算效率跟内存之间的差异,CPU和内存之间会存在一个叫CPU的高速缓存,每个CPU核心的高速缓存是对于其他CPU核心来说是不可见的。这样就会出现缓存一致性问题。
这应该怎么解决?在CPU层面提供两种方式,总线锁、缓存锁。
总线锁就是锁总线,就是当我们的一个CPU核心在执行一个线程去访问数据去操作的时候,它会往总线发一个LOCK信号,其他处理器再请求我们主内存的时候,它就会被阻塞,那么该处理器可以独占共享内存。总线锁相当于把CPU和内存之间的通信锁住了,这就导致一个性能问题,多核的目的就是做负载,通过多核去做一个任务的负载,然后提升整个的运行效率,因为单核的性能已经达到一个瓶颈了没有办法提升,所以它就做了一个分片,就这个概念。那这个目的就是为了提高性能,那你加了一个总线锁,又反而让他变成一个串行化执行,那这个地方又有什么意义呢?所以P6系列以后的处理器,出现了另外一种方式,就是缓存锁。
缓存锁
如果缓存在处理器缓存行中的内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理不在总线上声明LOCK信号,而是修改内部的缓存地址,然后通过缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据,当其他处理器回写已经被锁定的缓存行的数据时会导致该缓存行无效。
所以如果声明了CPU的锁机制,会生成一个LOCK指令,会产生两个作用
- Lock前缀指令会引起引起处理器缓存回写到内存,在P6以后的处理器中,LOCK信号一般不锁总线,而是锁缓存
- 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
缓存一致性协议
处理器上有一套完整的协议,来保证Cache的一致性,比较经典的应该就是MESI协议了,它的方法是在CPU缓存中保存一个标记位,这个标记为有四种状态。
如果我们在这个缓存加了缓存锁的话,它就会在汇编指令加一个#LOCK前缀的指令,这个指令的作用是,譬如CPU0改变了它对应的高速缓存的值,它就会把值同步到主内存,然后导致其他CPU的缓存无效。
MESI缓存一致性协议,在每个缓存里面设置一个标记位,这个标记有四种状态:
M(modify - 修改)- 表示当前的缓存被修改过,修改了缓存以后会导致其他核心的缓存失效,变成I,然后再从主内存获取数据,这个M和I的一个概念
I(Invalid - 无效)
E(Exclusive - 独有) - 独占缓存,当前cpu的缓存和内存中数据保持一直,而且其他处理器没有缓存该数据
S(Shared - 共享) - 共享缓存,数据和内存中数据一致,并且该数据存在多个cpu缓存中
每个Core的Cache控制器不仅知道自己的读写操作,也监听其它Cache的读写操作,嗅探(snooping)"协议
CPU的读取会遵循几个原则
- 如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取
- 如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为S
- 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为MC
这是从硬件层面CPU层面上去了解它为什么会产生 可见性、原子性、有序性问题。
CPU的优化执行
除了增加高速缓存以为,为了更充分利用处理器内部的运算单元,处理器可能会对输入的代码进行乱序执行优化,保证该结果与顺序执行的结果一致,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,这个是处理器的优化执行;
还有一个就是编程语言的编译器也会有类似的优化,比如做指令重排来提升性能。
并发编程的问题
缓存一致性就导致可见性问题、处理器的乱序执行会导致原子性问题、指令重排会导致有序性问题。为了解决这些问题,所以在JVM中引入了JMM的概念
内存模型
内存模型定义了共享内存系统中多线程程序读写操作行为的规范,来屏蔽各种硬件和操作系统的内存访问差异,来实现Java程序在各个平台下都能达到一致的内存访问效果。Java内存模型的主要目标是定义程序中各个变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量(这里的变量,指的是共享变量,也就是实例对象、静态字段、数组对象等存储在堆内存中的变量。而对于局部变量这类的,属于线程私有,不会被共享)这类的底层细节。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的可见性、原子性和有序性。内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障
java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存。主内存是所有线程共享的,工作内存是每个线程独有的。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。并且不同的线程之间无法访问对方工作内存中的变量,线程间的变量值的传递都需要通过主内存来完成,他们三者的交互关系如下
JMM的定义一个规则是这样子的,我们的线程去访问主内存(实例对象、静态字段、数组对象,对于多个线程是共享的)的时候,线程去访问主内存,必须先访问工作内存(栈内存),如果工作内存没有,就去访问主内存,它提供8个原子指令去实现线程跟我们工作内存的一个交互,通过这样的规则规范了对内存的读和写的操作。用来解决多线程通过共享内存进行通信的时候存在本地内存不一致性的一个问题。
总的来说,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性