多线程(二):synchronized与volatile

再说synchronized与volatile之前,先让我们了解一下CAS。

CAS(compare and swap)

CAS全称为compare and swap,比较与交换。因为经常与循环一起工作时又称为自旋、自旋锁或无锁,所以泛指一类操作。
在这里插入图片描述
CAS具体思路为:设有两个线程A、B。线程A先拿到变量值i,假如为0,如果然后做操作将变量值i+1,然后再去读取这个值,看是否为0(是否有线程改写该值),如果为0说明没有线程改写,那么就写入1;反之则重新读取改变后的值,再做+1操作然后继续判断,直到没有其他线程改写该值,这个过程就为compare and swap。

但CAS会有ABA问题出现。所谓ABA问题就是,在上述思路中对变量i进行操作时,“如果为0说明没有线程改写,那么就写入1”,但是实际上这个i已经被改写过了,即如果变量值i初始值为0被改写后,再改回0,此时线程A是发现不到的,这就是所谓的ABA问题。
ABA问题的解决办法为:对i加版本号,如果期间更改过,版本号会改变,然后再加以处理。

在java中,大量的程序用到了CAS操作,例如原子类AtomicInteger。通过阅读AtomicInteger类中方法的实现代码,我们可以发现,在底层的具体实现过程中,大量的用到了CAS操作。对于多线程环境而言,多个线程对一个变量值进行更改时,如果不加以处理,就会出现数据不一致的情况,而AtomicInteger之所以能够满足在多线程环境下多个线程对变量值操作后的数据一致性,得益于代码中比较底层的指令:lock cmpxchg。
lock cmpxchg->CAS底层实现代码涉及,CAS底层使用该指令保证原子性
在我个人的理解中,
cmpxchg -> 汇编语言的指令,和CAS的思路相同,也就是说CAS底层实现中直接对应着汇编语言的cmpxchg指令
但cmpxchg指令本质上不保证原子性
所以CAS操作具有原子性的原因是lock指令
lock -> 汇编语言的指令,两个指令结合起来看,意思为当前cpu执行cmpxchg指令时,其他cpu不能对其中的值做修改

我们今天要讨论的synchronized与volatile在底层也会涉及lock指令,也就是说,synchronized 与 volatile关键字的底层实现都与lock指令有关。

对象的内存布局

要了解synchronized底层实现之前,我们首先要了解一下什么是对象的内存布局。

一个对象的普通内存布局分为以下几个部分:

  1. markword 保留了对象锁信息,GC信息等
  2. class pointer 指向了类元数据信息
  3. instance data 对象中的数据
  4. padding 对齐填充

在这里插入图片描述
其中,markword与class pointer称为对象头header,haeder是分析synchronized的重点,接下来会解析。剩余的instance data很好理解,就是存储一个对象中的数据信息;padding的存在是因为对象的内存布局是没有采用固定大小的存储方式的(个人认为是实例数据不同以及非固定模式比较节省空间),所以为了方便管理内存中的一个个对象,对于不能对8整除的对象大小(单位是字节Byte)进行填充,填充至对8整除。

通过使用openJDK的包JOL来查看对象中的存储布局
对于一个普通对象的内存布局而言,在64位JVM环境下:

  • markword 8字节
  • class pointer 4字节(原本是8字节,但是JVM默认开启了指针压缩后,压缩至4字节)
  • instance data 根据实际数据为准
  • padding 填充至被8整除

举个例子:
new Object()
我们可以计算出,对象头head所占字节大小为12字节(markword占8字节,class pointer压缩后占4字节),实例数据0字节,但是由于12字节无法被8整除,故填充4字节至16字节,也就是说,new Object()这个操作不考虑其他因素,其在内存中占16字节。
如果再加上对象引用o,指向这个对象,即:Object o = new Object() 那么在内存中就是20B(o压缩后占4个字节)

再举一个例子:
User u = new User();
假如User中有两个变量,int id,String name。new User()需要多少个字节呢?
markword 8B
class pointer 4B
int 4B
String 4B(普通对象指针压缩)
padding 4B
共24个字节

了解了对象的内存布局,我们在看看header中markword到底有什么。
在这里插入图片描述
根据上面我们可以得出,一个对象的对象头header中markword占8个字节,也就是64位。这64位决定了synchronized在对象加锁、锁优化过程中的记录。

synchronized

现在,我们终于要了解synchronized的原理了,但在了解之前,我们还要回顾一下我们再程序代码中是如何使用synchronized关键字的,具体可以看我的另外一篇博客:
多线程(一):Synchronized与ReentrantLock

  • 代码阶段
    在使用synchronized之前,我们首先要在程序代码中添加关键字synchronized,这一点是毋庸置疑的。
  • 编译期
    在程序编译过程中,java编译器会将我们的程序代码转换为jvm能读懂的字节码,也就是.class文件。
    通过浏览字节码中的源码,我们可以发现在添加synchronized的代码块中(如果添加到方法上就是方法体内),出现了两条指令monitorenter / monitorexit。
  • 运行期
    在JDK1.6之后,java开发人员对synchronized底层做了许多优化,也就是锁升级过程,这个锁升级过程就是在运行期进行的。
  • 汇编语言
    通过浏览程序比较底层的源码,我们依然可以发现lock cmpxchg汇编指令

也就是说,synchronized关键字底层还是通过lock cmpxchg实现的同步(加锁机制)
synchronized底层的实现:
1.java代码:在源代码加synchronized关键字
2class字节码:monitorenter / monitorexit 监视器进入与退出,
3.jvm执行过程中进行锁升级
4.lock cmpxchg汇编指令

synchronized锁升级过程

锁升级是JDK1.6对synchronized关键字的优化,在1.6之前,synchronized被认为是重量级锁(现在也是,只不过在“真正”synchronized之前,添加好多手段),性能不高。
synchronized锁升级的过程。
new -> 偏向锁 -> 轻量级锁(无锁、自旋锁、自适应自旋)-> 重量级锁
这一过程记录在markword(包含锁信息、GC信息),我们再来引用上面的图
在这里插入图片描述
在这里插入图片描述

我们来整理一下思路:

  1. new阶段
    当对象刚被new出来时,默认是没有锁的,此时对象锁状态就为上图中的无锁态。
  2. 偏向锁阶段
    在对象被new出来后,一个线程来访问尝试对其加锁(synchronized(this)),那么对象的锁就会升级为偏向锁状态。因为此时只有一个线程尝试对该对象加锁,所以JVM并不会对其真正的加锁(因为没有其他线程来争抢锁),而是在其markword中记录当前指针的线程ID,并同时将偏向锁位置1表示该对象在偏向锁状态。
  3. 轻量级锁/自旋锁/无锁阶段
    这时又有一个线程尝试对该对象加锁,即目前有两个线程竞争该锁,此时的锁便升级为轻量级锁状态。这个时候的锁会将偏向锁位收回,并留出部分空间保存一个线程的地址信息。这个地址信息是每个线程在线程栈中生成的一个Lork Record对象地址。对于此时竞争的两个线程而言,每个线程都生成了自己LR对象,并尝试用CAS(文章最开始的时候介绍过)的方式去尝试向该空间写地址,最终写成功的线程将拥有对该对象的控制权,而另外的线程则自旋等待或进入第4阶段。
  4. 重量级锁阶段
    在多个锁竞争该对象时,或最大自旋次数(默认10)超过还没有得到该对象的锁时,那么就会进行“真正”的加锁阶段,也就是底层向OS申请将用户态转为核心态操作操作系统底层的互斥量,进而真正的分配锁。

JDK1.6以后,JVM引入自适应自旋,默认打开,也就是将轻量级阶段的锁自旋次数或时间交由JVM处理。
前三个阶段是JDK1.6基于synchronized的优化,对于这三个阶段来说,程序都还在用户态上运行,并没有转换到核心态去申请资源,所以性能很高,到了第四阶段,才真正申请了锁。

对于JDK1.6其他的优化,请看下面两个图
在这里插入图片描述

在这里插入图片描述

volatile

volatile的底层实现设计到的知识更多,也更复杂,这里只是附上个人的浅见。
volatile关键字最大的两个作用是线程可见性禁止指令重排序,下面分别介绍这两种作用的思路:

  1. 线程可见性
    JMM中分为主内存和线程特有的工作内存,每个线程对主内存中的变量做操作时,首先要将主内存中的变量拿到本地的工作内存中(实际上是因为线程运行在CPU不同的核中,这个变量拿过来放在了CPU的寄存器(做完运算在放到高速缓存Cache,然后再读,这样会提高cpu读取速度)中),然后在进行操作,当操作完后,在把已经更改的变量值写回到主内存。
    但是如果是多线程的情况下,线程A拿到变量值i到自己的工作内存做循环+1操作,在A没有做写回操作之前,B线程又来取主内存中i的值,它取的还是i原来没有改变的值,A、B完成对变量的操作后,相继做写回主内存的操作,就会出现脏数据的情况。
    加了volatile后,线程在每次对变量做操作后(例如+1),都会将变量值写回到主内存中,当该变量值被改变后,其他线程也会重新来主内存取变量值,这样的操作就称为线程可见性,即每个操作对其他线程都是可见的。但是还是改变不了可能出现脏数据的情况。
    这是因为,A先拿到了主内存中变量i 的值后,假如被阻塞了,在没有写回主内存之前,B线程读了主内存变量的值(没有加锁,不会互斥,可以读),尽管这时B将变量值刷新到主内存,会通知A在他下次对i的操作时要重新读A,但对此时的A来说已经拿到了该值,也就是说读取的这个原子操作已经结束了,它会做完操作后再继续写回i,这时i还是出现了脏数据的情况。因此volatile保证了可见性,不能保证原子性。
  2. 禁止指令重排序
    cpu会出现乱序执行指令的情况,重排序相当是一个优化的操作,增强了cpu处理的速度,在单线程模式下,不会有什么问题,但在多线程环境下,就有可能出现程序执行逻辑的错误。
    volatile会禁止指令重排序,具体是通过内存屏障。volatile修饰的变量首先要连接内存屏障,内存屏障的作用是内存屏障两侧的指令不可以重排序,保证了指令的有序性。
    说到内存屏障,其实它是java的一种规范或规则,其实就是在指定指令之前加了一层屏障,屏障两侧的指令不能重排序,这样就保证了目标指令无法重排序,也就是volatile达到禁止指令重排序的作用。
    在这里插入图片描述

说完了volatile具体的作用后,我们再来看看volatile的实现原理:

  1. 代码层面
    代码层面加了volatile
  2. 字节码层面
    加了标识符ACC_COLATILE
  3. JVM层面
    jvm的内存屏障,JSR(关于内存屏障的规范)
  4. CPU层面
    CPU层面的底层实现,还是lock指令,在底层加锁了
    在这里插入图片描述

volatile为什么不能保证原子性?
下面以volatile变量自增为例:

public class VolatileDemo {
    public static volatile int race = 0;

    public static void increase(){
        race++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[20];
        for (int i=0 ;i<20;i++){
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i=0;i<1000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() >1){
            Thread.yield();
            System.out.println(race);
        }
    }
}

反编译字节码文件可以看到,自增操作会分为三个步骤:1.读取volatile变量的值到local;2.增加变量的值;3.把local的值写回让其他线程可见。可以看到,volatile不能保证原子性。

Code:
       0: iconst_0
       1: istore_1
       2: iload_1
       3: sipush        1000
       6: if_icmpge     18
       9: invokestatic  #2   

在这里插入图片描述

一道面试题:DCL(double check lock)单例模式是否要加volatile?
DCL就是实现单例模式时采用的两层判断机制来实现内存中是否只有一个对象实例。
如果开始时我们指定Instance i = null;时不加volatile,那么就会造成指令重排序,那么在多线程环境下,一个线程去初始化该对象时,造成了指令的重排序,导致了另外一个线程直接拿到了半初始化状态的对象,那么就会造成数据不一致的情况。
所以,DCL(double check lock)单例模式需要加volatile,不加可能会出现使用半初始化的对象的情况,因为会出现指令乱序,没有进行初始化指令的时候,就已经建立了引用与对象的连接。

在这里插入图片描述
在这里插入图片描述

synchronized与volatile的区别

  1. volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

volatile引申的更底层的东西

在此之前,我们要先了解一下CPU跑程序的过程。
在这里插入图片描述
一台机器的核心由CPU与内存组成。简单来讲:
当我们写好一个程序准备运行时,OS首先会将程序写入内存中。
CPU从内存中读取程序代码(这么说比较草率,应该执行是程序底层的指令):
CPU从指令寄存器PC(记录下一条指令执行位置)中读取指令的位置,读进指令;
将指令中涉及的数据存到寄存器Registers(存数据)
并利用计算逻辑单元ALU计算。

但是这个过程太慢了,CPU计算的速度已经发展的非常快了,这就使得从内存中读取数据是CPU计算能力的瓶颈之一,因此引入了Cache高速缓存。高速缓存Cache的作用就是缓解CPU与内存之间传输数据速度慢的问题,通过Cache,CPU可以快速的读取缓存数据,大大提升了CPU的处理速度。
在这里插入图片描述

在这里插入图片描述
上图中L1、L2、L3都是高速缓存,启示L1、L2是CPU的每个核独享的,L3是共享缓存。

超线程:所谓超线程就是一个ALU对应多个PC及Registers,也就是所谓的四核八线程

看完以上内容,我们了解一下Cache Line的概念。
在这里插入图片描述
当CPU想要读取某个数据时,先要到最近的Cache L1中去找,找不到去L2去找,进而L3,如果都没找到,那么才会去主存去找。那么什么是缓存行呢?
我们知道内存从硬盘中读取数据时因为局部性原理读取的数据是按块读进来的,Cache从主存中也是这样。
Cache从主存中读取的块成为缓存行,这个缓存行大小为64个字节。
所以当读取数据时,如果两个数据在内存中的存储位置是相邻的,那么大概率会将两个数据都按照一个缓存行读到内存中来。

Cache提高了CPU读取数据的速度,但在开发过程中,还可以利用这个缓存行这个特性进行性能的提升,这就是缓存行对齐
缓存行对齐:用多余的字节,将两个变量分别位于不同缓存行,如果两个线程分别读两个变量时(一个线程读一个),是不会通知其他变量的,就会提升性能。
个人理解:在使用volatile关键字时,如果使用了缓存行对齐,就会精准的将volatile修饰的变量加入缓存行中,从而使得程序更加有效率,不必反复去主存中读其他的值
在这里插入图片描述
在这里插入图片描述
MESI缓存一致性协议

MESI是CPU核之间的数据一致性协议。
针对的是Cache Line中的数据,有如下四种状态:
在这里插入图片描述
缓存一致性协议
处理器上有一套完整的协议,来保证 Cache 的一致性。比较经典的是MESI 协议,它的方法是在 CPU 缓存中保存一个标记位,这个标记为有四种状态:
Ø M(Modified) 修改缓存
Ø I(Invalid) 失效缓存
Ø E(Exclusive) 独占缓存
Ø S(Shared) 共享缓存
每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读写操作,嗅探(snooping)协议。CPU 的读取会遵循几个原则:
1、如果缓存的状态是 I,那么就从内存中读取,否则直接从缓存读取
2、如果缓存处于 M 或者 E 的 CPU 嗅探到其他 CPU 有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为

缓存一致性问题
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。
CPU-0 读取主存的数据,缓存到 CPU-0 的高速缓存中,CPU-1 也做了同样的事情,而 CPU-1 把 count 的值修改成了 2,并且同步到 CPU-1 的高速缓存,但是这个修改以后的值并没有写入到主存中,CPU-0 访问该字节,由于缓存没有更新,所以仍然是之前的值,就会导致数据不一致的问题。为了解决这个问题,CPU 生产厂商提供了相应的解决方案:

总线锁
当一个 CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信号。其他处理器的请求将会被阻塞,那么该处理器可以独占共享内存。总线锁相当于把 CPU 和内存之间的通信锁住了,所以这种方式会导致 CPU 的性能下降,所以 P6 系列以后的处理器,出现了另外一种方式,就是缓存锁。

缓存锁
如果缓存在处理器缓存行中的内存区域在 LOCK 操作期间被锁定,当它执行锁操作回写内存时,处理不在总线上声明 LOCK 信号,而是修改内部的缓存地址,然后通过缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据,当其他处理器回写已经被锁定的缓存行的数据时会导致该缓存行无效。所以缓存锁会产生两个作用:
1、将当前处理器缓存行的数据写回到系统内存。
2、这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

系统底层如何实现数据一致性(CPU)
1.MESI如果能解决,就使用MESI
2.如果不能,就锁总线
3.但是这不是JVM采用的方法

系统底层如何保证有序性
1.内存屏障sfence mfence lfence等系统源语
2.锁总线

参考:
https://www.jianshu.com/p/1ae887521cf3
https://www.jianshu.com/p/7d3425a78d72

发布了352 篇原创文章 · 获赞 73 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_43777983/article/details/105012072