1. volatile底层
  2. 重排序
  3. 又细又多又繁琐
  4. 解决内容:
    1. 在高并发情况下,java内存模型是怎样提供支持的
    2. 一个对象new出来之后,在内存中到底是怎样布局的


硬件层的并发优化基础知识(硬件—->jmm在硬件基础上是如何实现的)

  1. 指令重排
  2. happens-before原则
  3. as-if-series
  4. 八大原子指令
  5. 上述都要靠到硬件上去,所以先讲硬件
  6. jmm是在硬件上进行架构的

image.png

超线程—->两个线程在同一个cpu里面跑

disrupter和listtransferqueue中都用了缓存行对齐的方式来提高效率

伪共享问题:使用缓存行对齐能够解决伪共享问题产生的效率下降,但是同时也会浪费空间

合并写技术(读可以乱序,写可以进行合并)

  • 硬件层面上还有一个WCBuffer的缓存,这是最快的缓存,比L1缓存还要快,但是他所有的空间也是最小的,只有4byte的空间,只要4byte一满,他就会讲缓存中的内容写到L2缓存中去或者内存里去
  • 在一条指令往L2中写的时候,因为太慢了,所以会将之后的对同一个变量的写命令一起执行(假如后续指令也改变了这个值),会合并到一起,扔到一个合并缓存中去,最后将最终的结果扔到L2中去

乱序的证明

  • 美团的Disorder程序:(x,y)只能出现(1,0)、(0,1)(1,1),不可能出现(0,0),若真实运行的时候出现了(0,0)说明在程序的执行中可能发生了重排序
  • 乱序会产生问题—->volatile保证不能乱序执行,有些情况不能乱序执行(有些情况不能往里面写必须先读,有些情况不能先读必须先写才行)

cpu级别的内存屏障和jvm层面的内存屏障

  1. 硬件级别如何保证
  2. JVM级别如何规定,只有规范,具体的实现还得通过c语言操作硬件的那个级别

cpu级别的内存屏障(这里指x86的)

  • 不同cpu的内存屏障是不一样的,指令也是有区别的
  • intel的cpu的内存屏障:sfence、lfence、mfence

    有序性保障 CPU内存屏障 sfence:在sfence指令前的写操作当必须在sfence指令后的写操作前完成。 Ifence:在Ifence指令前的读操作当必须在Ifence指令后的读操作前完成。 mfence:在mfence指 令前的读写操作当必须在mfence指令后的读写操作前完成。

jvm层面的内存屏障

  • load是jvm层面的内存屏障???
  • 不是说只有硬件层面上的内存屏障才可以实现jvm层面的内存屏障(硬件上也可以用lock指令来实现)
  • jvm级别的东西和硬件层面的东西有的不是一回事
  • 线程工作内存是对cpu告诉缓存的抽象吗?——>不能说的这么粗狂,严格来讲,线程工作内存就是jvm规范的东西,而jvm要把什么装到内存中去是虚拟机自己的事;线程工作内存现在可以认为是包括cpu告诉缓存的,他是jvm规范实现上的一个对应关系

image.png

  • 上图不是实际中的物理层,而是JVM虚拟机规定的一个模型,是实际物理实现的一部分,这个模型是怎么实现的要看虚拟机各自的实现,
  • 工作内存完全可以是物理内存(告诉缓存和主内存)的一部分(前面的说法是完全可以的)**,主要看JVM虚拟机是怎么实现的**
  • storeload屏障是“全能屏障”,硬件层级mfence屏障是全能屏障
  • 编程趋势:volatile不用了,都用synchronized(全用synchronized),因为synchronized经过优化后的效率其实也非常高并不比volatile差多少,只有在那种非常追求效率的情况下要用volatile,假如不是在那种非常追求效率的情况下没必要用volatile


volatile的实现细节

  1. 硬件
  2. 软件
  3. jvm
  4. 字节码混着讲
  5. 有好几层,不同的层级有不同的实现(字节码bytecode实现—->jvm实现—->os/hd实现)

字节码层面

  • ACC_VOLATILE

JVM层面

  • 对于所有的写操作前面加了StoreStoreBarrier,后面加了个StoreLoadBarrier
  • 对于所有的读操作前面加了LoadLoadBarrier,后面加了个LoadStoreBarrier
  • volatile内存区的读写都加屏障

硬件层面

  • JVM层面的volatile在硬件层面是如何实现的
  • hsdis—-HotSpot Dis Assembler虚拟机的反汇编,把虚拟机编译好的字节码再进行反汇编,观察jvm编译好的字节码在cpu级别是用什么汇编指令完成的(下面文章讲的是在windows上的实现,在Linux上可能是另一个实现)

image.png

  • 在Windows上是用lock实现的,没有用lfence、sfence或者mfence;在Linux上据说是上面一个屏障、下面一个屏障、最后再加一个lock;每个上面不太一样

volatile的实现是分不同层级的

synchronized的实现细节

字节码层面

  • synchronized修饰方法的时候,只会加一个synchronized的修饰符ACC_SYNCHRONIZED
  • 而synchronized修饰代码块的时候会有monitorenter和monitorexit这两条指令
  • 中间的monitorexit表示发现异常之后会自动退出

image.png

  • 字节码层面没有太复杂,比较简单,没有牵扯到具体cpu的实现

JVM层面

  • C/C++写的,调用了操作系统的同步机制
  • C/C++与具体系统相关,在不同的OS内核上有不同的实现(linux、windows上都会提供同步机制—->互斥区、同步区),通过同步区互斥区来提供不同的互斥机制
  • 看操作系统内核提供的函数就行了

硬件层面

  • 硬件层面基本上用lock指令来实现!!!
  • lock指令:lock comxchg ××××(x86)
  • comxchg指令是CAS指令(compareAndExchange)
  • cpu级别实现同步—->lock一条指令
    • 比如将i从0变成1(可能好几条指令,不能同步)
    • lock comxchg(0, 0, 1),在执行这条指令的过程中这块内存是被lock住的—->这就进行了同步,内部就完成了synchronized
  • synchronized(this){ }有一堆需要同步咋办?
    • 很简单,一条lock指令,comxchg(this),change this的时候只有lock住了这个东西之后,后面的才能继续执行,如果执行不了(要想执行只能lock他),如果lock没成功又跳回来JNE,原来从0到1,现在从1到2;这样就保证了不同的线程之间是不可能乱入的
    • 保证了一个线程执行到中间,另一个线程也来执行了(有个lock comxchg在那!!!
    • 因为lock comxchg二话不说上来首先干lock aomxchg这件事,你上来把0变成1,我上来就要把1变成2;如果你把1变成不了2,那么后面的就执行不了(这样就将那一块代码给锁住了
    • 上面就是硬件层面上的实现

java的8大原子操作(原来的虚拟机规范)

  • 现在的JSR-133已经放弃了这样的描述,但JMM没有变化

image.png

  • JMM没有变化,只不过描述方式发生了变化,不再拿这种方式去描述JMM规定的一些指令、规范了

happens-before原则(顺序、排序)

  • 是java语言的要求,由具体的java虚拟机去实现,本质上实现也是用了前后不能错顺序的方式(上面讲的内容)
  • 这是java语言的规范,jvm实现java语言的时候必须遵守这个规范
  • 说的是有些指令不能重排的问题,由具体的jvm去实现的
  • 本质上还是有些指令不能进行重排的问题
  • 可以去读jvm的规范JVMS和实现以及java语言的规范JLS(优先级低)—->oracle网站上下载—->17节的17.4Memory Model下的17.4.5有happens before order

image.png

as if serial

  • 不管如何重排序,**单线程执行结果不会改变**
  • 最后的结果不变
  • 重排序不会影响最终的结果,要和没有重排序的结果一样!!!

对象的创建过程

对象的内存布局

  • 观察虚拟机配置:java -XX:+PrintCommandLineFlags -version

    普通对象

  • 在hotspot虚拟机中对象头叫做markword,占8个字节

  • ClassPointer指针,class的指针,对象属于哪个class,对象中有一个指针指向那个class的类对象(T.class)
  • 实例数据:成员变量,int m=9; String是引用指向另外一个
    • 引用类型:-XX:+UseCompressedClassPointers为4字节,不开启为8字节(默认是开启的?)
    • Oops Ordinary Object Pointer
  • Padding对齐,8的倍数,真正按块来读,不是真正按多少个字节来读,一下子读16个效率反而会更高,所以要通过填充来进行对齐

    数组对象(多了一项)

  • 对象头:markword 8

  • ClassPointer指针同上(数组中装的是什么类型的东西)
  • 数组长度:4字节
  • 数组数据(真正的数据)
  • 对齐 8的倍数

对象的大小

  • java中没有sizeof这之类的获取大小的方法(一般动态语言也有这个方法)
  • java的agent机制:在字节码文件被load到内存的过程中可以使用agent代理截获这些class文件(0101101011101……),并且可以任意进行修改,截获到就可以任意修改,就可以读出来object的大小
  • 一般用不上,跟抓包一样,做这个实验要用他
  • 这个代理必须得自己去实现(可以任意修改class文件)
  • 类似asm
  • 将代理装到jvm上去,写一个类,然后打一个包,需要配置一下premain-class(MAINIFEST.MF文件),指定好提前在main方法之前运行的class是哪一个

image.png

  • 那个类中要有premain方法,这个方法是固定的,里面的参数也是固定的(与main方法一样格式是固定的),第二个参数是Instrumentation
  • 这个方法是虚拟机自动调用的,如何拿到Instrumentation调琴师:通过premain方法拿到jvm传给这个方法的调琴师赋值给自己的成员变量(jvm自动调用,jvm会传过来他的调琴师)
  • 调琴师中有一个getObjectSize的方法,这样就能知道对象的大小
  • 在idea中打成一个jar包,拿到别的项目中去用,这个东西实际就是一个agent,把这个jar文件当成一个agent给用到另外一个项目中去
  • jar文件代表的就是agent(了解)
  • 在别的项目中用agent的时候必须加参数-javaagent:c:\work\……\ObjectSize.jar
  • javaagent有很多用处,阿里的调试gc的工具arthas就是用javaagent完成的

  • 指针压缩打开就会把原来8个字节的类指针压缩成4个字节(在java中原来的指针是64位的,即8字节的???,压缩之后就变成了4字节
  • 最终必须是8的倍数
  • 数组的**classpointer**不是oops,这是错的!!!(网上是错的,误导性特别强)
  • 在64位的机器上引用类型(ref)的大小是64位的(总线宽度、大小)即8字节
  • Oops是ordinary object pointers—->普通的对象指针
  • Oops与ClassPointer是不一样的,两者之间的压缩命令也是不一样的(两个是不同的压缩选项!!!),Oops是**-XX:+UseCompressedOops**,而ClassPointer是**-XX :+UseCompressedClassPointers**
  • Oops与ClassPointer是分开的,一个是普通对象指针,一个是类对象指针;并且这两者的压缩也是分开的
  • String类型是引用类型
  • 网上有n多文章将Oops安到ClassPointer上去了❌
  • 这个实验自己做,做不出来就背过
  • HotSpot开启压缩是有一些原则,这些原则了解一下(内存不是越大越好)

image.png

对象头中具体包括了什么

  • 非常复杂
  • 各个版本的实现也不一样(此处以1.8为例)
  • 需要看虚拟机的源码了—->markword的结构定义在markOop.hpp文件中(c++)
  • 32位怎么实现的,64位是怎么实现的,其中有多少位是不用的,里面哪两位代表的是什么内容(特别复杂)
  • 下图中的那个表是用32位的情况在说明64位的情况,所以表格中hashcode25位,而下面的文字说明有31位!!!(不要眼花,要认真看!要仔细看!小心谨慎!!!

image.png

  • 不同对象状态下,记录的内容是不一样的
  • 面试题:markword里面装的是什么呀?
    • 锁定信息:两位代表该对象有没有被锁定,锁定的意思是synchronized(这个对象—->this、o、p),当锁定了这个对象的时候,用这两位来标记这个锁的标志位
    • gc的标记:表示对象被回收了多少次了,他的年龄是多少,指分代年龄!
  • 64的头中有8个字节,其实是看对象的状态来真正分配这64位,在普通状态下是什么样的,在锁定状态下是什么样的,在不同的状态下这64位的情况是不一样的
  • 严格来讲是里面的三位表示锁的状态,而不是两位(实际中是三位)

  • 在无锁状态的时候,那个hashcode也并不是显式显示在markword中的,不是记录在这里的,只有调用了这个对象的hashcode的时候,他才会被记录在markword中
  • 这里存的是按原始内容计算的hashcode(这个hashcode是identityHashCode),重写过的hashcode方法计算的结果不会存在这里???❓
  • hashcode比较特殊,分两种情况
    • hashCode被重写过:根据重写的逻辑决定hashcode的值,❓此时不存放到markword的hashcode位置上去???❓
    • hashCode没有被重写过,hashCode是根据对象后面的这些内存的具体情况算出来的值,❓一旦生成了这个hashcode,就会将这个hashcode放到markword头中去—->什么时候生成hashcode呢?—->调用未重写的hashcode方法或者调用了System.identityHashCode方法的时候❓
    • 一共占25位(cpu系统是32位的情况)
  • 32位与64位差不了太多

image.png

  • gc的回收状态与gc的垃圾回收相关,CMS是gc的其中一个过程:CMS promoted
  • 总之,不同的状态,前面的markword表示的是不同的内容—->复杂

  • 锁标志位分别代表无锁、偏向锁、轻量级、重量级、gc标记(gc也会用到锁(stop-the-world))
  • 如何出面试题,搞那些自己关心的面试题(实际中用到的那些自己比较关心的新技术之类的、或者是自己想解决的问题)做成面试题,让面试的人现场打开机器来解决就可以了
  • IdentityHashCode的问题:当一个对象计算过identityHashCode之后,不能进入偏向锁状态,可能会直接进入重量级锁状态,因为hashcode可以存在重量级锁的monitor中,而在偏向锁或者轻量级锁中无法存放

image.png

  • 上图是正确的,如果已经计算过hashcode,就相当于那一块被偏向锁给占了,这时候偏向锁就进不来了

image.png

  • asa

    对象定位(面试官卖弄的过程)

    • T t = new T();
    • t是如何找到new出来的那个对象的?(两种方式)
      • 句柄池(间接指针):t指向两个指针,一个指向对象,一个指向T.class(相当于中间隔了一下,中间单独拿出来了—->实际对象和类对象)
      • 直接指针:平时常话的,t直接指到实际对象,在实际对象中有一个class指针指向T.class

    image.png

  • 句柄池和直接指针没有优劣之分,有的虚拟机实现用第一种,有的用第二种(Hotspot用的第二种)

    • 第二种效率比较高,直接就找到对象了
    • 第一种效率相对比较低一些,因为他需要找两次指针(需要找一个指针再找一个指针),但是句柄池的方法在GC垃圾回收的时候效率比较高
      • GC的时候牵扯到一些算法,后面会说(比如说三色标记算法CMS回收器)
      • 使用三色标记算法进行回收的时候假如用句柄池效率会相对比较高,使用第二种效率会比较低

对象怎么分配

  • 与GC相关的,讲到GC时再讲
  • 公开课
  • 非常复杂
    • 尝试往栈上分配,栈一弹出,这个对象就没了
    • 如果栈上那个分配不下,特别大的对象,直接分配到堆内存的老年代
    • 如果对象不大,首先进行线程本地分配,能分配下就分配,分配不下找eden区
    • 然后进行GC,年龄到了就进入老年代;年龄不到就GC来GC去