在本文中将会涉及到多线程的基础概念,以及相关的内存模型同时引申出一些问题做出解释,在阅读本文之前建议大家去看一看我 JVM
专栏下的文章,Java内存模型大揭秘,因为我下面等待内容很多都涉及到 Java
对象模型的相关知识。本文将会很长,请大家耐心食用。
什么是进程
进程是操作系统运行程序的基本单位,在 Java
中我们运行一个程序就是一个 Java
进程,我们可以在 Windows
下的任务管理器可以看到系统正在运行的进程,很多很多。
什么是线程
线程是操作系统进行进程调度的最小单位,一个进程可以包含多个线程,比如我们的 Java
程序,在启动主线程之后,还会有垃圾回收的线程等等。
什么是并行
两个或两个以上的系统作业在同一时间段内执行。
什么是并发
两个或两个以上的系统作业在同一时刻执行。
什么是同步
一个调用发起之后,会等待结果返回,结果不返回,自己不结束。
什么是异步
一个调用发起之后,不等待结果返回,自己直接结束。
上面就是一点小概念,帮大家整理一下,省得概念混淆。
我们为啥要用多线程
其实我们最主要的目的就是加快,生活节奏加快,程序也得加快,毕竟科技发展很迅速嘛,人也变得浮躁起来,什么都讲究个快,谁都不想网上买个东西还得排个队啥的是吧。扯远了,我们用多线程就是为了充分利用CPU
的能力,让程序能够尽可能的接受和处理用户的请求。
多线程引发的问题
那么随之而来的就是多线程产生的问题,毕竟一心多用这种事,咱们自己也搞不明白呢,计算机也一样。首先第一个就是线程安全问题。
怎么理解线程安全问题
简单来说就是我们多线程操作一份共享数据,我们期望的结果和实际的结果是一样的就是线程安全,不一样就是线程不安全。举个例子,一个蛋糕分5 块,五个人同时拿,我们期望肯定是一人一个哈,但是呢,有个人和你想拿的是一个,咋搞?先来后到?打一架?拼背景?这就是我们需要讨论的问题。当然了这个问题比较复杂,我准备放在后面再说,放心,挖坑管埋。
单颗 CPU 单核的情况下多线程一定效率高吗
我为啥还写上单核,就是因为现在发展的牛啊,一个 CPU
可以挂多个核心,其实在操作系统中一个核心在同一个时刻只能执行一个指令序列。而我们常说的多线程在单核的情况下的执行其实都是多线程之间的切换,这就是上下文切换 ,线程之间的切换依赖于 CPU
的时间片轮转 ,每个线程执行到哪里都需要记录的,然后切到线程执行的时候可以继续,这样轮转起来就产生了并发,毕竟要雨露均沾嘛。对应到 Java
的对象内存模型中,我们知道每个线程栈有自己专属的程序计数器,获取下一个执行指令,多线程切到你执行的时候得知道继续执行什么对吧,这都是相通的。
但是凡事都有个度。线程可以从执行的类型上可以分为两种,一种是 CPU 密集型
,也就是计算和逻辑运算很多,一种是 IO 密集型
的,也就是读网络,磁盘啊等等的。任何切换都是需要代价的,对于两种类型,CPU 密集型
的开很多线程就代表着很多的开销,可能会影响性能,对于 IO 密集型
的,可能会提高效率。IO
是需要其他的硬件协作的,需要等待时间,这个等待时间完全可以干别的,但是计算这种开很多线程反倒耽误 CPU
工作了。但是啊,很多也有个度,不是无限制的开,生产队的驴也不能这么用。
Java 中的线程的生命周期是什么样的
说到生命周期,其实就是一个事情从开始到结束中间经历的状态变化。先看个图,
来自《Java 并发编程的艺术》
在上面的图中已经说明了每个状态到每个状态的触发方式
- NEW初始状态: 创建一个线程,但是没有调用
start
方法 - RUNNABLE 运行态: 调用了
start
方法 - BLOCKED阻塞态: 等待锁释放
- WAITING 等待状态: 等待其他线程通知或者中断
- TIMED_WAITING 超时等待状态: 可以在指定的时间返回,而不是
WAITING
一样一直等待 - TERMINATED 终止态: 线程运行完毕
图中写了 RUNNING
和 READY
这两种是操作系统的状态,Java
不区分的原因是因为,在操作系统中时间片的切换就是 READY
和 RUNNING
之间的线程切换,这个时间太短了,每个线程在 CPU
执行的时间大概只有 10~20 毫秒,所以对于 Java
来说没必要分开,反正都是要执行的。
上面也说了 BLOCKED
这个状态等待锁释放,后面再详细说锁的事,这里简单介绍一下,我们加锁的目的就是为了线程安全,比如我们常用的 Sychronized
,让访问共享数据在同一时刻有一个线程,那阻塞就是我想要锁,但是没给我。锁的问题就会出来了,那就是死锁。
什么是死锁呢?
简单形容就是线程 1持有苹果的锁,线程 2 持有香蕉的锁,都在执行,然后线程 1 想要香蕉,线程 2 想要苹果,谁都不让着谁,死这了。。。比如下面这个例子
代码来源《并发编程之美》
java
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}
如何避免死锁
第一种,一次性申请完,别拖拖拉拉的。第二种,申请不到资源,就把自己持有的放弃,着啥急呢。第三种,按顺序请求资源,比如上面代码,你先请求 1,再请求 2,让线程 1执行完释放你不就能执行了。当县长最重要的是什么?忍耐。。
Sleep 和 Wait 方法
这两个方法都是暂停线程啊,有啥区别呢?从归属上面说 Sleep
属于 Thread
类的静态方法,Wait
属于 Object
的方法,工作原理不同,Sleep
方法是让线程暂停执行,但是不释放锁,而 wait
是释放对象锁。执行 wait
之后,其他线程可以继续获取对象锁,同时线程不会自己唤醒,需要别的线程在对象上执行 notify
方法或者 notifyAll
方法,给大家丢一个 demo 自己玩一玩
java
public static Object object1 = new Object();
public static void main(String[] args) {
new Thread(()->{
try {
synchronized (object1){
int count =1;
while(true){
count++;
System.out.println(count);
if(count==100){
object1.wait();
}
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(()->{
synchronized (object1){
System.out.println(456456);
object1.notify();
}
}).start();
}
run 方法和 start 方法
上面的代码中看到我都是调用的 start
方法,run
方法是线程的重写方法,也就是执行方法,当调用 start
方法的时候其实是调用了native
的 start0
方法,交给操作系统干这个事,调用 run
方法执行执行线程的一个普通方法,并不会单独启动单程执行。
上面也看到了我创建线程的一个方式,对于创建线程的方式自己搜一搜吧,比如继承 Thread
,实现 Runnable
接口,或者像例子这样Lambda
表达式等都可以。
CPU 缓存模型
说Java
内存模型之前还是要说一下这个 CPU
缓存模型,其实等了解多了的时候就会发现,世界大同小异,一样的问题不同的领域其实都会出现,这个还是个很有意思的事情。我们之前说过线程安全问题是对共享变量处理会导致,在 CPU
处理的时候也会。
首先还是先说一下为什么 CPU
要设置缓存。CPU
进行计算的时候无可避免的要拿数据,而数据主要就是存在内存中也就是主存和磁盘中,众所周知,操作系统读取内存的速度要远大于磁盘,但是为了更加提升 CPU
的处理效率,CPU
也是有缓存的,这个读取缓存的速度要比内存还要快很多。 如上图,每一个CPU
核心都会有自己的缓存区域,也就是L1
和L2
,L3
是CPU核心共享的,然后通过总线,最后写入主存,但是这也是有问题的,也就是线程安全问题,这个问题和Java
中的内存结构中也是类似的,不同线程操作同样的一个共享变量就会有线程安全问题,同样的道理,CPU
在自己的核心中处理完同样的数据然后写入主存的时候也存在这样的问题,所以现在的操作系统基本都是在总线处增加缓存一致性协议,比如MESI
,有兴趣的大家可以搜搜。
Java内存模型
那么什么是Java
的内存模型呢?后面我会用JMM
来代替内存模型解释。我们上面知道了操作系统是解决了数据不一致的问题的,那我们为什么不可以直接复用操作系统的内存模型,还要定义一套Java
的内存模型呢?这个就是Java
很关键的一个特性,跨平台性 ,为了在不同操作系统中呈现同一个应对多线程程序方案,Java
就需要这样的一个模型来对应,同时还要尽可能的屏蔽我们对底层的多线程的处理方式,比如sychronized
和Lock
等。
那么Java内存模型是什么样的?还是先上个图
其实我们可以发现和CPU的逻辑结构非常像,对共享变量存储在我们的主存中,比如堆,而每个线程都会有自己的本地内存,类似我们的线程栈,好了,我们线程很多,如果对一个共享变量进行处理的话那么写回到主存的时候就不可避免的会产生线程安全问题,同时赋值层面会引发问题,其实编译层面也会存在问题,也就是我们经常听说的指令重排序。
指令重排序
通俗的解释就是计算机执行的代码顺序可能和你写的不一样。Java
源代码在进行编译的时候会首先进行编译器优化重排,编译器再进行编译的时候在不改变单线程语意的前提下,会重新安排执行顺序,它会认为,反正不影响你结果,我就按我舒服的方式调整一下。其次呢,CPU
也会进行指令并行重排,将多条指令并排执行,在数据不产生依赖的时候会影响指令的执行顺序,所以在多线程的环境下就会产生线程安全问题。
既然有了指令重排序,那么就会有禁止指令重排序的方式,对于编译器会禁止特定类型,比如使用volatile
,而CPU
会通过使用内存屏障来禁止重排序,比如写写屏障,写读屏障等等,大家可以自己看看。既然编译器重排序,程序员还不想让你重排序,所以就有和happen-before
happen-before
happen-before
就是来平衡的,就是满足程序员的需求,然后让编译器尽可能的去进行优化,说白了,关键的东西听我的,剩下的随你便。happen-before
的结果就是让第一个操作的结果对第二个操作可见。那么详细一点就是happen-before
有几种规则。
- 一个线程内,我第一个操作的结果,对第二个是可见的。
- 解锁操作要发生在加锁之前。
volatile
变量写操作之后,对其以后任何的操作都是可见的- 如果a
happen-before
b,bhappen-before
c 那么a对c是可见的 - 一个线程的
start
操作发生在任何操作之前
上面的太多概念性的东西了,其实最主要的就是我们知道线程安全是咋来的,后面我要说的都是和线程安全相关,首当其冲就是volatile
关键字
volatile关键字
我们所熟知的volatile
的两个特性,一个就是保证内存可见性,第二个就是禁止指令重排序。我们前面也说了CPU
是有缓存的,而volatile
保证内存可见性就是要禁用CPU
缓存,变量都是到主存中获取的。第二个特性就是使用内存屏障来禁止指令重排序。但是volatile
不能保证原子性,也就是读取,赋值,写回主存这三步不是原子性的,这也就导致了在多线程的情况下,可能对这个变量操作存在差异,比如我对一个变量使用多线程进行++操作,得到的结果可能和实际不符。所以一般我们都会用锁的方式,比如Sychronized
,在介绍Sychronized
之前我还是先说一下相关的概念。
悲观锁与乐观锁
悲观锁的概念就是,你访问资源的时候,我认为你一定修改,所以,你在访问的时候我就直接上锁。
乐观锁的概念就是,你访问资源的时候,我认为你大多数都是读,而当写的时候通过一些方式去校验你是否被其他线程修改了。
所以显而易见的特点,悲观锁在高并发下会造成线程拥堵,增加系统开销,甚至回产生死锁的情况。乐观锁在在处理请求的时候有比较好的性能,但是当发生写操作的时候就会频繁的失败,然后重试。
所以悲观锁和乐观锁只是概念上的事情,而实际上使用的时候要考虑很多。对于乐观锁而言要进行其他线程是否修改的校验,怎么校验?版本号机制或者CAS
,这个版本号其实我们会用到一种,就是用更新时间,精确到毫秒之后,用这个去校验其他线程是否修改过。
CAS
另外就是CAS
,这个被用到了各个框架中,翻译过来就是比较与交换。比较什么?交换什么?比较你这个变量还是不是原来的值,也就是变量的值和预期的值相不相等,比如我希望给迪丽热巴一个戒指,结果来了个凤姐,我还给不?你愿意给你给,我可不给。交换什么?如果相等的前提下,就代表你没有被更新过,那我就可以赋值了,就把戒指给你了。
但这也引发了CAS
的一个经典问题:ABA问题
,就是一个线程将变量值从A改成了B,又一个线程从B改成了A,对于现在要更新的线程来说,我擦嘞,你没改,但实际上改了。所以在Jdk1.5
以后,增加了引用和标志来解决 ABA 问题。
CAS
主要依赖于操作系统的一个命令 cmpxehg(Compare exchange)
,这个命令的作用就是锁住总线,看我上面 CPU
的那个图,你任何变化都根据主存中的进行比较然后进行的。所以说 Java
或者 JVM
也是要依赖于操作系统完成我们想要的功能的。
Sychroniezd 关键字
那么接着说这个非常关键的关键字,Sychronized
。这个关键字主要就是用来解决多个线程访问资源的同步问题,它可以保证修饰的方法或者代码块在同一时刻只能被一个线程执行。它也是慢慢发展起来的,在早期的 Java
版本中它是属于重量级锁,就是依赖于操作系统的 Mutex Lock
实现的,每一次的线程切换都需要涉及到用户态转内核态,时间成本很高。在 Java6
之后引入了自旋锁,自适应自旋锁,轻量级锁,然后又有了锁粗化,锁消除等技术让它的效率提升了很多,然后就慢慢的应用起来的。
对于这个关键字的使用无非就是三种,第一种就是修饰实例方法,锁住的就是实例对象,第二种就是修饰静态方法,锁住的是当前类,第三种就是修饰代码块,锁住实例对象或者类。具体使用自己搜一下,不是我要说的重点,我要说的是它的原理。
Sychronized 原理解析
先说第一种情况,Sychronized
修饰同步代码块的场景,修饰同步代码块其实就是在方法内部了,那么我们可以理解成在方法的内部干了一些事情。
在Java
中每一个对象都内置了一个 ObjectMonitor
对象,顾名思义,就是对象监视器,也就是用于监控对象状态的。而 Sychronized
在执行的过程中依赖两个命令,一个是MonitorEnter
,一个是 MonitorExit
,也就是监视器进入和监视器退出,当执行 MonitorEnter
命令的时候会会通过监视器来获取锁的计数器,ObjectMonitor
中存储两个属性,一个是持有该对象锁的线程,一个是锁的个数,当然这个个数不是指多个线程,而是针对重入的情况,Sychronized
是一个可重入的锁,可以在 Sychronized
中继续加锁。当计数器的个数为 0 代表可以获得该对象的锁,否则就获得锁失败了,而 MonitorEnter
就是对计数器进行-1。
第二种情况,修饰方法的时候,它是用了一个 ACC_SYCHRONIZED
的标识,标明这个方法是一个同步方法,然后 JVM
根据这个标识来执行同步调用,如果是一个实例方法那么就获取实例对象的锁,如果是静态方法那么就获取类的锁。但是不管你获得是什么的锁,本质上都是通过 Monitor
的机制实现的,毕竟在 Java
中万物皆对象。
Sychronized 中的锁升级过程
上面我说了在 Java
的早期版本中,它依赖于重量级锁,而后面逐渐的增加的很多的技术来提升性能,这就是我要说的它的锁升级过程。而这个过程和我之前讲 Java
对象结构是息息相关的。
Java
对象的内存结构就是三种,对象头,实例数据,和数据填充。对象头中,存了 MarkWord
,对象类型数据的指针,如果是数组的话,那么存储数组的长度。和锁相关的就是这个 MarkWord
,不同的锁的状态下这个 MarkWord
存储的内容是不不同的。我们先说一下锁的升级过程。
无锁->偏向锁->轻量级锁->重量级锁
在32 位和 64 位操作系统中,存储 Markword
内容的位数是不同的,我们不用纠结这个,而是专注于它存储什么。
无锁状态下: 对象的 hashCode
,对象的分代年龄,是否是偏向锁(1 位标识)0,和锁标志位01,无锁状态下的锁标志位:001
偏向锁状态下: 线程 id,epoch
偏向的时间戳,对象分代年龄,是否偏向锁 1,锁标志位 01. 这里就发生了变化,就是用线程 id+epoch 代替了 hashcode
,偏向锁的标志位为 101.也就是当执行同步代码块的时候,会记录第一个获取到该对象权限的线程,然后记录下来,而偏向的含义就是偏向第一个线程,也就是没发生竞争,如果还是这个线程获取锁,那么我还给你。
轻量级锁状态: 指向线程栈锁记录的指针,锁标志位 00,这代表的是轻量级锁,到这里就发生很大变化了,这里举个例子就好理解了,每个线程比喻成自己的小家庭,大家都要用这个菜刀,我拿到了,然后我就写上菜刀在我家我用着呢,这就相当于指针,然后其他家就等着我用完,然后拿到他家用,这个过程也叫做自旋。谁拿到了,谁就修改指针就行了。所以就是每个线程有自己的 Lock Record(LR)
,然后讲对象的 Markword
复制到自己这里用。
而这个自旋的过程就引入了自适应自旋锁,就是根据自旋的时间和成功几率动态调整自旋的次数和每次自旋的时间,达到某个阈值,继续升级为重量级锁。
重量级锁状态: 指向内存对象的 ObjectMonitor
对象,锁标志位 10.到这了就说明你这竞争的有点严重了,如果再依赖自旋,那么太消耗 CPU
性能了,毕竟所有线程在那咔咔自旋,这就依赖于操作系统的互斥量来解决了,就是别特么自旋了,有人用着呢,就都给我等着,等人家用完了,唤醒你们,你们在竞争。
以上就是锁的升级过程。
锁消除
锁消除不是指的你上面的锁直接变没啊,这个是一种技术手段,通过逃逸分析技术实现的,比如你的对象是在线程栈内分配的,并不会逃逸到其他线程使用,那么加不加锁没啥意义,所以这就是锁消除。比如
java
public void test(){
Object object = new Object();
synchronized (object){
System.out.println(123);
}
}
当然了,一般咱们也不会这么写。
锁粗化
还有一种优化技术是锁粗化,不是把锁直接升级啊!而是JIT
动态编译的时候发现,你怎么在循环或者线程里对一个对象重复加锁,他分析没有影响的话,会给你优化一下,变成一个粗粒度的锁,也就是扩大范围。