您的位置:首页 > 编程语言 > Java开发

【Java从头开始到光头结束】No6.多线程与高并发回顾

2020-06-22 20:23 405 查看

JAVA 多线程与高并发

基础回顾 → 多线程与高并发
————————————————————————————————————
主要内容来自三部分:
一:腾讯课堂马士兵老师公开课
二:《码出高效:Java开发手册》
三:自己经验总结

1.什么是线程

现在在你的电脑上安装完QQ之后,他们的安装文件是一堆静态的实体,或者说静态的数据和脚本文件,他们就代表的是程序,只是一组指令的有序集合,它本身没有任何运行的含义,
其中有一个QQ启动程序 program app → QQ.exe
当你点击启动这个程序,QQ running → 进程,就会起来一个进程,进程是一个动态的实体,它有自己的生命周期。它因创建而产生,因调度而运行,因等待资源或事件而被处于等待状态,因完成任务而被撤消。反映了一个程序在一定的数据集上运行的全部动态过程。
进程和线程都是由操作系统所调度的程序运行的基本单元,系统利用该基本单元实现系统应用的并发性。线程是进程最小的执行单元,线程也被称之为轻量级的进程。
程序的运行都需要加载到内存中才可以和cpu进行交互,但由于目前cpu的处理速度远大于内存,所以cpu有能力在单位时间内去分配多个时间片来执行不同的程序,虽然它在某一时间片内只能单独执行一个任务,但是电脑总是给我们多个程序在并行的错觉,这是cpu快速在多个程序执行间切换执行的结果,这是cpu将执行力分配到多个进程上保证运行效率的体现
而进程对于线程,就好比于cpu对于进程,是进程将执行和处理资源分配到多个线程上来保证程序运行效率的体现
我觉得这么比较好理解,总结一下就是,一个程序跑起来之后至少会有一个进程,一个进程启动起来之后,其中至少会有一个线程。这种一对多的情况存在的意义就是为了在某种情况下,适量修改多的一方的数量,能让cpu执行处在一个相对任务饱满的状态,达到效率最大化。
————————————————————————————————
附上一个复杂版的解释连接:程序,进程,线程的区别和联系

2.如何创建和启动线程

线程的启动有多种方式:

  1. 继承Thread类,重写run方法。

    启动此线程时直接将其new出来调用start就可以了。

2.实现Runnable接口,依然重写run方法。

启动此线程时需要将其作为参数传入Thread类中再调用start方法。相对于第一种,推荐这种方式,便于扩展,更加灵活,对外暴露的细节比较少,可以专注于实现run()方法。(第一种往往不符合里氏代换原则)

关于这种实现接口的线程创建方式,在JDK1.8之后有一种方便的写法,是利用了匿名内部类的方式。

  1. 使用Callable和Future创建线程,实现Callable接口,重写其call()方法,使用Future接口的实现类FutureTask(这个实现类既实现了Future接口,还实现了Runnable接口,因此可以作为Thread类的target)来构建线程。
FutureTask<Integer> future = new FutureTask<Integer>(
    (Callable<Integer>)()->{
      return 5;
    }
);
new Thread(task,"返回值=5").start();

Callable和Runnable有两点不同:
1.call()方法有返回值,无论你是继承自Thread还是实现Runnable接口,都有的一种缺陷就是,在任务线程完成后,无法直接获得结果,需要借助共享变量等获取,而Callable和Future很好的解决了这个问题。
2.call()方法可以抛出异常,Runnable只有通过setDefaultUncaughtExceptionHandler()的方式才能在主线程中捕捉到子线程的异常。
————————————————————————————————
最后想强调的一下的是,有些人把从线程池中获取线程称作一种线程的创建的方式,也无可厚非。

3.线程的基本方法

  • sleep():当前线程暂停一段时间(参数),让其他线程运行。
  • yield():当前线程退出一下cpu执行,回到等待队列,让其他等待队列中的线程有可能得到执行权,当然也有可能还是刚yield()完的当前线程继续获取到cpu的执行权。
  • join():在当前线程执行中加入另一个线程,比如在t2的线程中使用了t1.join(),那么t2线程在执行到t1.join()的时候t2线程会停止,转而去执行t1线程,当t1线程执行完毕,又回来接着执行t2线程,相当于t2线程中添加了t1线程。join()方法常用于等待一个线程执行的结束。
  • getState():获取当前线程状态。

4.线程的生命周期

线程可以拥有自己的操作栈、程序计数器、局部变量表等资源,它与同一进程内的其他线程共享该进程的所有资源。线程在生命周期内存在多种状态。如图所示
有 NEW (新建状态)、RUNNABLE (就绪状态)、RUNNING (运行状态)、BLOCKED (阻塞状态)、DEAD (终止状态)五种状态。

1.NEW 即新建状态,是线程被创建但未启动的状态。
2.RUNABLE 即就绪状态,是调用 start()方法之后,运行之前的状态。在等待队列中,另外,线程的start()方法不能被多次调用,否则会抛出 IllegalStateException 异常。
3.RUNNING 即运行状态,是 run () 方法正在执行时线程的状态。线程可能会由于某些因素而退出 RUNNING状态,如时间、异常、锁、调度等等。
4.BLOCKED,即阻塞状态,进入此状态有以下种情况。

  • 同步阻塞锁被其他线程占用。
  • 主动阻塞,调用 Thread 的某些方法,主动让出 CPU 执行权 ,比如 sleep(), join() 等。
  • 等待阻塞 执行了 wait()。

5.DEAD,即终止状态,是 run()方法执行结束,或因异常退出后的状态,此状态
不可逆转。

————————————————————————————————

5.synchornized

①synchornized基础
synchornized关键字,它的使用就是当多线程访问同一资源的时候,需要对资源上锁,这里上锁的代码块也称之为临界代码或者临界区,也可以叫同步区,这里需要强调的一点就是synchornized关键字锁的目标是一个对象,而不是代码块,只是需要先获取这个对象锁之后,才能执行synchornized中的代码,例如下边执行synchornized包起来的代码块得先获取o对象的锁。(锁住的对象必须是同一个,不然达不到互斥的效果)
那么,什么是对象的锁呢,synchornized关键字的实现对于JVM来说是一种规范,而不是具体的实现步骤,不同的JVM可能有不同的实现方式,但他们都得符合synchornized关键字的规范,规范就是synchornized关键字就是用来做代码同步的,至于如何同步,看你JVM的实现方式,比如在Hotspot中关于synchornized关键字的实现,就是在我们对象的头(64位)上选择固定的两位来标识这个对象当前的锁状态,比如01代表被某一种锁给锁定了,00代表没有没锁定这种形式。当然上边这种方式只是举例,一般也不会这么写,比较方便的方式是直接锁定当前自己这个对象:

下边这种方式和synchornized(this) 锁定当前对象 的意思是一样的:

当你锁定的是静态方法时,类似于锁定的是本类的class对象,该类所有的实例在调用此静态方法时都需要获得锁。
补充一下,锁的对象不要是String常量或者Integer,Long等基础类型,String常量可能关联着其他类库,而Integer等基础类型值改变的话对象也会改变,不建议使用。

关于synchronized是锁对象还是锁代码,以及【synchronized+普通方法】和【synchronized+静态方法】的区别请参考下边博客,有举例的详细说明,这里不再重复阐述:
synchronized锁住的是代码还是对象
————————————————————————————————
synchronized锁还有一个重要的概念是可重入,也称之为可重入锁,同一线程中,有多个锁方法,但锁的是同一个对象,当其中一个方法调用另一个方法时,是可以执行的,类似于下方在m1方法中来调用m2方法,他们锁的对象都是this,如果锁不可重入的话,在m2调用的时候就发现m1已经上锁了,m2就在等m1释放,那就死锁了。所以此时当m2调用的时候,发现锁住this对象的还是当前线程,那就允许你继续访问了,这就是可重入锁,记住此实验前提是同一线程的锁方法互相调用,并且锁的是同一对象。

下边是一个父子类的重入锁实验(锁的都是子类的this对象),和上边同理

这里加synchornized的原则是 锁的范围尽可能小,锁的时间尽可能短,即能锁对象,就不要锁类,能锁代码块,就不要锁方法。
再补充一点就是,在临界区代码如果产生异常,则锁资源会被释放,可能会被其他想要获得锁的线程所占有,导致最后数据可能会不一致。

②synchornized进阶
先上一张图:

下来我们正式开始,首先说明一下为什么会有CAS这个概念,以及这个CAS和synchornized又有哪些关联,为什么学习synchornized得先知道CAS。
首先什么是CAS,它是compare and swap的英文缩写,是比较和交换的意思,自己装过Linux虚拟机的同学应该记得磁盘分区的时候有一个swap区,就是交换区的意思。
CAS是用来干什么的,首先,他可以在不用上锁(这里的锁指的是操作系统,内核概念的锁,是重量级的锁)的情况下完成并发操作,因为锁的消耗比较大,想要使 对互斥资源的操作 变得更轻量级,诞生了CAS,在并发量不大的情况下,或者并发操作非常快的情况下,使用CAS会比synchornized锁更加效率化。
————————————————————————————————
CAS又叫无锁或者自旋锁,看图,它的工作原理也非常简单,假如现在有一个共享资源变量值为A,第一个线程S1,将A读到自己线程的内存空间中进行计算处理,得到结果D,这时他需要把D的值写回给A,在写回之前,S1会获取到最新的共享资源变量,看最新的值是不是还是A,如果还是A,就说明在我修改的这段时间内没有别人动过这个共享资源变量,那我就直接用D的值覆盖共享资源变量,就是比较和交换。
假如在S1线程操作途中有线程S2把A的值从A改成了B,在S1写回的时候就发现值被改过了,就会重新再来一次,读取B再进行计算,最后返回。
这其中有一个明显的问题,就是ABA问题,S1线程改之前共享资源变量值为A,其中共享资源变量值经历了其他两个线程S2和S3,S2将共享资源变量值从A改到B,而S3将共享资源变量值又从B又改回到A,这时S1修改完毕回来发现共享资源变量值为A,就直接覆盖了,根据你具体的业务,这里可能会有问题,因为中间值没有得到使用,怎么解决呢,最简单的方法就是加版本号,类似于乐观锁。
这里我们算是先对CAS有了一个简单的理解,
举例说明一下CAS在Java中的使用:

// 在Java并发包JUC下有已经使用了CAS的类,例如
// AtomicInteger类的incrementAndGet()方法:
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}

// compareAndSet(current, next)底层最终调用的是
// unsafe类的compareAndSwapInt方法,名字是不是很眼熟
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

// 再看一下Unsafe类:
// 使用native关键字说明这个方法是原生函数,也就是这个方法是用C/C++语言实现的,并且被编译成了DLL或者SO(Linux下),由java去调用。
// 要想知道compareAndSwapInt的实现就得去看JVM虚拟机的源码
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

下来我们看看CAS和synchornized关键字的关系,synchornized在JDK版本的不断优化后,已经不再是一个之前的重量级锁,他现在有多种锁的实现,其中有一个锁升级的概念:
偏向锁→轻量级锁→重量级锁
————————————————————————————————
让我娓娓道来,在说偏向锁之前还得再了解一个小知识,就是对象的内存布局和MarkWord的小细节
看图,我们有一个小T对象,new出来之后再堆内存中给他分配了内存空间,这内存空间主要就分为四部分(可以用openJDK提供的JOL工具来查看对象内存布局)
第一部分是MarkWord,在头部用来做一些标记,标识。
第二部分是Klass pointer,是一个指针,用来指向T.class,默认是四个字节。
第一部分和第二部分也统称为对象头。
第三部分是类属性,比如m,是int类型,占四个字节
第四部分是padding,用来做补充对齐的,64位的虚拟机需要这个堆内存空间大小能被8整除,如果上边三个部分加起来不能够被8整除,那么padding就会补位。
下来我们用JOL看一下T对象的内存布局

当我们给T对象加上synchornized锁之后,再来看内存布局

是不是发现了些许变化,所以我们获得出来的第一个结论是,我们所谓的锁上一个对象,其实就是在他的头部分,MarkWord区域进行了标识,也就是存储锁信息(锁状态),这也是对象MarkWord的第一个作用,下来附上一张64位虚拟机的各种锁状态下头信息部分的内存布局图,马老师首发
MarkWord中除了存储锁状态信息以外还有什么?
一,还有GC标记信息,垃圾回收器的标识。
二,hashCode值,如果有调用的话。

具体是如何标记锁信息的呢,还是这张图,在我们markword 8个字节,64位的长度里,最后两三位用来做锁的标记
比如无锁状态叫001,偏向锁是101,轻量级锁是00,重量级锁是10.
再回来看这张图,就是无锁态到自旋锁的升级过程(为什么跳过了偏向锁后边会说):
这里稍微总结一下,MarkWord是啥?是对象在堆内存空间的头信息,主要存储上边的三个信息,我们平时说的加锁,其实就是修改了MarkWord对应锁状态信息位置的值。
————————————————————————————————
了解过内存对象布局和MarkWord后,重新回到synchornized锁升级的概念:
偏向锁→轻量级锁→重量级锁 上图
首先呢,我们还是得简要说明一下什么是偏向锁,轻量级锁和重量级锁,从大范围来讲,偏向锁和轻量级锁是用户空间的锁,重量级锁是操作系统空间,内核的锁,synchornized在JDK1.2的时候很慢就是因为他没有锁升级的概念,上来就是重量级锁,直接获取内核资源来控制,一上来就放大招,有点过分。后来逐渐优化就有了偏向锁和轻量级锁的概念,那么什么是偏向锁,我直接来一点书上的内容,很详细(里边提到的ThreadID就是MarkWord中的一小部分)

这里首先CAS和偏向锁挂上勾了,再来说一下轻量级锁,大概分为自旋锁和自适应自旋锁,后者是前者一个应用场景上的变化,也不能说完全就是优化,两者的实现都是一样的,我们就只说明一下自旋锁,当你第一个线程A访问到共享资源,假设ThreadID默认值是0000,修改了ThreadID为1111,等到第二个第三个线程B,C都来了,要争夺这个资源的使用权,就从偏向锁进化为了自旋锁,此时默认还是偏向于A线程,但是会有锁撤销的概念,将ThreadID返回到默认值,同时A,B,C线程来使用CAS修改ThreadID,谁修改到了,谁执行,剩下没抢到的这两个线程开始一种类似于死循环的操作(目标可占有ThreadID为0000,如果是A抢到了ThreadID为1111,B,C读取到发现不是0000,那说明还有人在用,B和C就CAS执行失败,继续循环CAS),循环修改直到第一个线程释放资源将ThreadID重置为0000,修改成功,获得执行权,期间后边的线程不会挂起,也不会阻塞,更不需要保存执行场景,减少了线程资源调度的消耗,也不需要向内核申请大锁,并且完成了多线程的操作,这就是自旋锁,仿佛其他等待线程在旁边旋转等待一样。有人也把这种锁叫做无锁,不建议这么叫哈。。。
自旋锁也有明显的问题,虽然它不需要内核态的大锁,但是他耗内存啊,假如你一万个线程并发了,一个线程在执行,剩下9999个在自旋,这顶不住啊,假如他执行10分钟还没完,那CPU就拉满了,所以自旋锁适合在,并发量较小,对共享资源操作执行较快(释放锁的速度快)的场景下。重量级锁是不会消耗CPU资源的,他会有一个线程等待队列,线程在挂起状态。
补个面试题

————————————————————————————————
下来我们看偏向锁如何升级为自旋锁再如何升级到重量级锁
偏向锁升级为自旋锁很简单,当有第二个线程来使用当前的共享资源时,就会升级,上边书上的内容也说过了,偏向锁是用来降低无竞争开销的,不是互斥锁,当有竞争发生,就不归偏向锁管了。
那么轻量级锁如何升级为重量级锁呢?看hotspot在JDK1.6之前的实现方法

还有一种是看当前自旋的线程和cpu核数的关系,比如线程数超过当前cpu核数的一半时,这些都是老的概念,现在已经不是这些了,但是新的概念又是建立在这些概念之上的,得先了解过去,才能望眼未来,现在的实现就是上边提到过的自适应自旋锁,他旋转多少圈,线程数为多少才会升级为重量级锁,都是JVM自动去做计算来调度的,合适的时候他会做出升级的动作,比方说,上一个线程在10次自旋以内执行成功了,那么默认下一次自旋也会成功,此时自旋的次数可能会放宽。
总结为:根据同一个锁上的自旋时间以及锁的拥有者状态来决定。
如果竞争加剧 竞争加剧:有线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制 升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间。
————————————————————————————————
现在再回来看着一条线,就是上边我们说的锁升级大致过程
还有一种情况就是从偏向锁直接升级到了重量级锁,比如调用hashCode()、wait()方法会使锁直接升级为重量级锁
下来再看为什么new出来一个对象之后会有两个状态:

首先,偏向锁是默认启动的,你可以在启动是改变参数设置它为不可用,那么既然他是默认启动的,细心的小伙伴肯定发现了,我们上边那个图就是直接从new出来的没有锁状态直接到了自旋锁状态,是为什么呢,因为偏向锁默认有延时启动的概念
在偏向锁延时启动之前如果被并发访问了,那么直接就到自旋锁,Hotspot的实现是延时4秒

如何证明这四秒的延时呢,看代码,我们将当前线程睡个五秒

再看锁状态信息,101状态的偏向锁

那么为什么会有这个延时启动的概念呢,之前我们的实验就是直接从无锁态到了自旋锁状态,
原因如下
偏向锁的使用一定会比自旋锁效率高吗?答案是否定的,在我们明确知道一块资源会被多了线程并发抢占访问时,为什么还要先使用偏向锁呢,还多了锁撤销的步骤,让这一块资源直接进入自旋锁状态会比先【偏向锁→撤销→自旋锁】这个步骤好的多。保证效率。
延时四秒期间,因为JVM虚拟机自己有一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。

补个面试题
我们也可以通过修改虚拟机参数的方式来修改这个延时时间为0秒

-XX:BiasedLockingStartupDelay=0

如果设定上述参数 new Object () - > 101 偏向锁 ->线程ID为0 -> Anonymous BiasedLock 打开偏向锁,new出来的对象,默认就是一个可偏向匿名对象101

那么又有一个问题出来了,刚new出来的对象,加了偏向锁,他偏向谁呢,还没有被访问过,就是我们图上的状态:匿名偏向状态,就是没有偏向状态,等第一个线程访问时,真正的变为线程偏向状态(也就是修改MarkWord中的threadID)。

补上一个Java非标参数中关于偏向锁的参数查看
BiasedLocaking就是偏向锁的意思,例如参数中的最后一个UseBiasedLocaking值为true,意思就是默认使用偏向锁,还有BiasedLocakingStartupDelay就是延迟启动多少毫秒,值为4000就是延迟启动四秒。
————————————————————————————————
最后再稍微聊一下synchornized底层的实现,首先是JVM层的实现,使用监视锁(书上内容)

至于为什么有两个monitorexit,可能会有朋友有疑问,这个两次退出就是异常情况下的退出和正常情况下的退出分别有一个,所以总共是两个退出语句。
上边就是字节码bytecode,就是.class的底层内容,再往底层走来研究,使用hsdis观察synchronized的底层实现,就是在JIT动态,即时编译.class字节码文件后的汇编语言实现:

lock cmpxchg指令,cmpxchg这词就是compare and exchange,就是CAS,lock这个CAS,是啥意思呢,锁本质的实现就是有一个信号量,哪个线程可以先修改了它,就会获得锁代码块的执行权,所以最底层实现是lock 【cmpxchg = cas修改变量值】,但是好像还有很优化的实现,但是hotspot并没有使用,偷了个懒,并且lock指令大部分系统底层都支持。
在硬件层面就是:
lock指令在执行后面指令的时候锁定一个北桥信号(不采用锁总线的方式)
————————————————————————————————
这里,synchronized就先告一段落。

6.volatile

首先volatile呢,有两个特性,我们以这两个特性开始展开:
一:线程可见性
二:防止指令重排序

————————————————————————————————
首先是:一:线程可见性
线程可见性的实现与JMM有关,就是Java的缓存模型 Java Memory Model,在我们的主内存中有一个共享变量,在java的缓存模型实现中,线程会把主内存中的变量拷贝一份到线程本地的内存中,然后对这个变量疯狂操作,但是修改的是本地缓存值,最后才写回主内存,但是这个过程默认别的线程是不清楚的,是黑盒的,加上volatile关键字才能让其他线程可以实时的了解到这个共享变量的变化情况。看个简单的小程序,这里即使主线程中将flag改为了false,子线程也无法停止,因为他使用的flag是本地缓存中的值,一直为true。
这里大家肯定有很多问题,首先第一个,为什么线程会有本地缓存,看书上是如何说明的

第二个,volatile关键字是如何实现线程可见性的?
关于线程可见性,后边我们还会继续进行细节说明。
————————————————————————————————
下来是:二:防止指令重排序
在了解防止指令重排序之前,我们得先了解指令重排序,指令重排序呢又可以叫做cpu的乱序执行,看似是乱序执行,其实是一种指令优化,我们看书上的内容:
关于happens-beforeas-if-serial这两个名词不在阐述,可以看这篇博客:
Java并发编程之happens-before和as-if-serial语义

总结一下指令优化就是cpu在保证最终结果一致性的前提下,会自主的合并优化部分指令,以提升效率,这个出发点是没有问题的,但是在某些情况下会对我们的代码造成影响。
举个例子来确认指令重排序的存在,看代码:

public class test {
private static int x = 0, y = 0;
private static int a = 0, b =0;

public static void main(String[] args) throws InterruptedException {
int i = 0;
for(;;) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(new Runnable() {
public void run() {
a = 1;// ①
x = b;// ②
}
});

Thread two = new Thread(new Runnable() {
public void run() {
b = 1;// ③
y = a;// ④
}
});
one.start();two.start();
one.join();two.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
//System.out.println(result);
}
}
}
}

首先,我们不停的创建两个线程one和two,然后在主线程中join执行他们,假设没有指令重排序的情况下(线程中①和②,③和④的局部执行顺序都不会改变,就是说①一定在②前边执行,③一定在④前边执行),那么结果大概有以下三种情况
1.one先执行完,two接着执行,此时(x=0,y=1) → ①② ③④
2.two先执行完,one接着执行,此时(x=1,y=0) → ③④ ①②
3.one,two交替同时执行完,此时(x=1,y=1) → ① ③④ ② 或 ③ ①② ④
这里线程中①和②,③和④的执行顺序不会改变的前提下,是不可能出现(x=0,y=0)的情况的,但是我们跑一下程序,发现确实出现了此情况:

这个简单的例子就证明了指令重排序的存在。一般只有上下指令没有直接关系的代码会被重排序,类似于这种a=1,x=a的这类有先后关系的代码是不会重排序的。

那么指令重排序会产生那些不好的后果呢,以一个面试题来说明,很经典的一个美团面试题,并且在阿里Java开发手册上也有对这个问题进行解决方案说明。本人不才之前还在部门的日会上给大家说明过这个问题,因为网上也有很多的关于这个问题的博客,这里我用代码+画图的方式简要和大家说明一下,先把问题抛出来:
在DCL单例模式下,单例对象是否需要加volatile修饰?
我们一步一步来说明,首先懒汉单例模式我相信不用多加说明了,都懂,上代码

// 情况1
public class LazySingleton {
private static T t = null;

public static T getInstance(){

if(t == null) {
t = new T();
}

return instance;
}
}

这代码单线程跑一点毛病没有,多线程就有问题了,多个线程同时跑到if(t == null)这里,就会new出来多个t对象,不符合单例模式的设计理念,那怎么修改了,最简单的我们刚说的synchronized直接用上不就行了,看代码

// 情况2
public class LazySingleton {
private static T t = null;

public synchronized static T getInstance(){

if(t == null) {
t = new T();
}

return instance;
}
}

这样解决了吗?是解决了,但效率不高,锁的力度太大了,能锁代码块,就别锁方法,万一这方法里边还有其他不用上锁的代码呢,例如一些日志的代码,所以我们开始减小锁的范围,有心急的朋友直接就这么干了。如下

// 情况3
public class LazySingleton {
private static T t = null;

public static T getInstance(){

if(t == null) {
synchronized(this) {
t = new T();
}
}

return instance;
}
}

这样写行吗?当然不行啊,这和情况1是同一个问题,只不过加了一个锁限制了new的顺序而已,多线程跑到if(t == null) 这里还是会创建返回多个t对象。得把if(t==null) 这句也包在锁里边才行,如下

// 情况4
public class LazySingleton {
private static T t = null;

public static T getInstance(){

synchronized(this) {
if(t == null) {
t = new T();
}
}

return instance;
}
}

这样就不会锁范围就下降了,速度肯定也会快起来,但是还是有点慢,慢在哪里,所有获得此单例对象的线程都得先获得锁,排队获取,效率很低,上边的问题是在创建t对象的时候会重复创建,以及锁了过多代码,这里的最新问题是没必要在获取的时候也加上锁,也就是在竞争创建的时候上个锁就可以了,确保只会有一个t对象,之后获取的时候别加锁了,怎么改呢,如下

// 情况5
public class LazySingleton {
private static T t = null;

public static T getInstance(){

if(t == null) {
synchronized(this) {
if(t == null) {
t = new T();
}
}
}

return instance;
}
}

这样一来,t对象建完毕之后,后边所有获取单体对象的线程就无须上锁排队了,效率自然高了,这里使用了两个if(t == null)的check,并且使用了锁,完成了单例模式的对象创建,所以这个创建方式叫做DCL→ double-checked Locking 单例模式。
这里看似解决了多线程的并发创建问题,又保证了读取效率,非常完美,其实挺多框架的源代码中单例模式就这么写的,但是,只能说这种写法是99.99%完美的,差那么0.001%,为什么这么说呢,上边我们了解到了cpu的指令优化,下来我们一起看一下

T t = new T();

这句代码执行的时候被拆分为几步,首先声明一下,这看似只有一句,但是new这个操作可不是原子性的。

class T {
int m;
public T() {
m = 8;
}
}

首先看一下这句new 的代码被编译为字节码以后会有几句:

总共有五句代码,和我们对象创建相关的三句都已经勾画出来了,接下来我们画个图,简要说明一下小t对象的构建过程,分为三步:

这里有的朋友可能已经发现问题会出现在哪了,就是在cpu指令优化以后,上边三步的顺序变为①③②了,当第一个线程走到①③这个步骤时(new到一半),t对象已经不为null了,此时其他线程进来判断if(t == null),发现已经不是null,就拿着t对象去用了,结果取出来t对象的m属性值是0,这就是问题所在,如何解决就是加上volatile修饰m属性,防止指令重排序,让①②③这个步骤不许重新排序。
(没有很高的并发是跑不出来这种情况的,可能得几十万上百万的并发,这个问题在阿里的Java开发手册中也有提及,不过里边只是推荐加上volatile,并没有说原因,毕竟这个问题很难用例子程序跑出来)

那么防止指令重排序是如何实现的呢?
首先从JVM级别的要求来讲,这是一个JVM的规范,对于Java代码中使用volatile修饰的堆内存空间,不允许进行指令重排序,至于具体的JVM,例如Hotspot如何实现,那是各个JVM的事情,这是王八的屁股,规定。
那如何保证两条指令不能换位置呢?这个概念就叫做 内存屏障,可以形象的理解为在两条指令之间加了一道屏障,让他们无法挪动位置。
在JVM中有四种内存屏障(逻辑概念):load的意思是 ,store的意思是 ,这四种屏障就很好理解,举个例子,第一种 loadload → 读读屏障,意思就是上边和下边同时有两条读语句,此时这两条读语句不能换位置。下边三种同理。

那什么叫做对volatile内存空间不允许指令重排序呢,如下图

在volatile的读写操作前后加上对应的读写屏障,达到禁止指令重排序的效果。这些都是逻辑上的概念。那么具体是如何实现的呢?以hotspot为例:
还是使用hsdis来查看volatile生成的汇编码:lock addl
lock addl 的意思就是在某个寄存器上加一个零,这有什么意义呢,答案是没有意义,虽然这里也是一个lock指令,但是他锁定的是一个空指令,那为什么他能实现线程可见性和防止指令重排序呢?真是奇了怪了,现在解开谜底:

LOCK 指令用于在多处理器中执行指令时对共享内存的独占使用。 它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。 另外还提供了有序的指令无法越过这个内存屏障的作用。

所以说,JVM(hotspot)底层使用了LOCK这一条指令就实现了线程可见性和防止指令重排序
那么他具体是如何让其他处理器的缓存失效的呢?
引出内容:缓存一致性协议MESI
首先声明这两者和volatile的实现没有半毛钱关系,但是其语义或者说表达的思想和volatile是一致的。
我们再说缓存失效之前,还是先来了解一下缓存的概念,先上个图
因为cpu的速度远大于内存,所以在cpu和内存之间使用对应的缓存可以使得读取的效率更高更快,通常分为三种缓存,L1,L2和L3(L0那其实也有缓存,这里先不说明),下边上图看一下访问他们的速度都是如何的:(registers就是cpu L0的缓存)
同时,当你的cpu是多个核心的时候,会有共享缓存L3的情况,L1和L2是每个核心都有一份的

当我们从主内存中读取内容到缓存中时,是一次读多少呢,假如内存中有一个x int类型的变量,他是四个字节,那么我会只读四个字节过来吗?答案是不会,从内存中读取数据是按块来读取的,会一次性把x变量所在的这一块内存内容拿过去,这个和总线宽度有关,假如你是64位的总线宽度,那么你读取64字节的内存大小内容和读取4字节的内存大小内容,读的大小是一样的,读过去的都是64字节的内容,只不过读四字节的那个内容中,剩下60位可能是空的。这读取的一个块的内容我们称之为一个缓存行:Cache Line

例如在内存中有一个数组,当你第一次访问了下标为0的元素,那你有可能还会再访问下标为1的元素,如果我可以一次读多一点内容的话,那么我为什么不把剩下的内容读到缓存中来呢,这样下次访问就会快很多了。
缓存的概念了解的差不多了,接下来说一说缓存失效,还是上边的图,当我们cpu1要读取x的数据,cpu2要读取y的数据,假设这两个数据在一个缓存行上,当cpu1修改了x的值,此时就会依靠某种协议去通知cpu2这个缓存行的数据失效了,你得去主存中重新再取一遍,所以,首先,失效的不是某个变量数据什么的,而是一整个缓存行,第二,依靠的这种协议就叫做MESI cache 缓存一致性协议,这种通知缓存失效的操作基本所有系统底层都会实现。
MESI cache 缓存一致性协议其实是inter cpu的一种缓存一致性协议,是cpu层级的协议,不同的cpu协议名称会不一样。MESI就是指缓存行的四种状态首字母分别是M,E,S,I。
上图
到这里,volatile的线程可见性说明也先告一段落。
————————————————————————————————
下边两个先欠着,等我看完书再回来写。#TODO

7.threadlocal

8.线程池

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: