深入理解JVM--读书笔记

一次线上项目OOM,在网上查询很多内容解决之后,无意之中也接触了一些JVM底层的概念,觉得很有趣,就买了这本书。工作之余看了看,简单写了点笔记。部分章节后续有时间会再细看,再补充笔记

1. Java内存区域与内存溢出异常

内存模型

包含了堆,栈,本地方法栈,虚拟机栈与程序计数器,其中方法区与栈线程共享

程序计数器

Program Counter Register,空间较小,可以看作是当前线程执行的字节码行号指示器。
如果线程执行的是Java方法,则计数器记录的是正在执行的虚拟机字节码的指令地址,如果执行的是本地方法,则是Undefined。此内存区域是唯一一个没有规定任何OOM情况的区域

Java虚拟机栈

存储局部变量表、操作数栈、动态连接、方法出口等信息,栈在更多情况下是指虚拟机栈的局部变量表部分。
基本数据类型与引用数据类型在局部变量表中的存储空间以局部变量槽来表示(Slot),其中64位长度的long和double会占用两个变量槽

Java堆

最大的一块,唯一目的就是存放对象实例。对于大对象(如数组)很可能要求连续的内存空间。如果没有内存完成实例分配且堆无法拓展时,会发生OOM

方法区

存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在JDK8以前,有人称为永久代,事实两者并不等价,只是这个区的内存回收条件比较苛刻。
运行时常量池是方法区的一部分

对象的创建:

当JVM遇到一条new对象指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代码的类是否已经被加载、解析和初始化。如果没有,那必须先执行相应的类的加载过程,然后为新生对象分配内存。对象内存大小在类加载完成后可完全确定。假设堆中内存是绝对规整的,以指针的形式来分割未使用的与已使用的,那分配内存则是指针向未分配内存的方向移动这个对象大小的距离,这种分配称为指针碰撞。如果队中内存不规整,那就要维护一个空闲列表,在列表中找到一块足够大的区域来存放对象。而内存是否规整取决于垃圾回收器的特性。
在并发情况下创建对象不是线程安全的。两种解决方案:一是分配对象动作进行同步处理–实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。另一种是将对象放在TLAB Thread Local AllLocation Buffer即线程的本地缓冲区中分配。
接下来JVM还要对对象进行必要的设置,例如是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码(实际上对象的哈希码会延后到真正调用hashCode方法时才会计算),对象的GC分代年龄,锁等相关信息
以上在JVM角度来看,一个新的对象已经产生了,但是从java角度来看,对象创建才刚刚开始,这是会调用初始化方法,一个真正可用的对象才会创建出来

对象的内存布局:

分为3部分:对象头,实例数据以及对其填充。
对象头包含两类信息:用于存储对象自身的运行时数据,如哈希码,GC年龄分代,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,称为Mark Word;另一类是类型指针,对象指向它的元数据的指针
实例数据是真正存储有效的数据

堆转储:

发生OOM可以使用堆转储文件分析,对象是否是有必要的,GC为什么没有回收掉,要么就加大内存空间

虚拟机栈和本地方法栈溢出

HotSpot虚拟机的栈不可拓展,所以除非在创建线程申请内存时就因为无法获得足够内存而出现OOM,否则线程运行时不会因为拓展而导致OOM,只会因为栈容量无法容纳新的栈帧而导SatckOverflowError异常

方法区和运行时常量池溢出

2. 垃圾收集器与内存分配策略

引用计数算法,无法解决互相引用但两个都是垃圾的问题
可达性分析算法GC Roots

以上两种垃圾算法都与引用离不开关系。在此引入4中引用概念扩充

  • 强引用:最传统的引用定义,类似Object obj = new Object(),任何情况下,只要引用关系还存在,则不会回收掉被引用的对象
  • 软引用:还有用但是非必须的对象,在OOM之前,会进行一次软引用回收,回收完了还不行,才会OOM。SoftReference类来实现,用作缓存
  • 弱引用:非必须对象,无论内存是否足够,都会被回收。WeakReference类来实现
  • 虚引用:唯一目的是为了能在这个对象被回收时,能收到一个系统通知。用作直接内存回收钩子

即便在根可达算法中被定义为垃圾,也不是非死不可,而是处于死缓阶段。要宣告一个对象真正死亡,至少要经历两次标记过程:没有与GC Roots的引用链,被第一次标记;随后进行一次筛选,条件是此对象是否有必要执行 finalize()方法,假如对象没有这个方法或者这个方法已经被虚拟机调用,则可暂免一死。如果有必要调用这个方法,则对象会被放置于F-Queue队列中,并稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的finalize()方法,但是执行只是触发,但是不一定会等待他们运行结束。因为如果某个对象这个方法执行缓慢甚至陷入死循环,那么其它对象就是永久等待,甚至导致整个内存回收子系统的崩溃。稍后收集器将对这个队列里面的对象进行第二次小规模标记,这时如果对象重新与引用链上任意一个对象建立关联,即可实现自救。

分代收集理论:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的;
  2. 强分代假说:熬过越多次垃圾垃圾收集过程的对象就越难以消亡

基于以上两点,收集器应该将java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中

专有名词:

  • Partial GC :指不是完整收集整个java堆的垃圾收集,分为:
  • Minor GC/Young GC :新生代垃圾收集
  • Major GC/Old GC:老年代垃圾收集。目前只有CMS由单独收集老年代的行为
  • Mixed GC:收集整个新生代与部分老年代,只有G1由这种行为
垃圾回收器的算法:
  1. 标记—清除。标记哪些是垃圾,然后清除。
    缺点:1.内存碎片化严重 2.当堆中有大量对象时,效率低
  2. 标记—复制。内存一分为二,有用的块直接复制到另一半,其余整片回收。简单高效
    缺点:浪费内存

拓展:实际上现在的商用java虚拟机大多都优先使用这种方法来回收新生代。且新生代的对象有98%都熬不过第一轮GC,所以不需要按照1:1的比例来划分新生代的内存空间
例如Appel式回收(HotSpot的Serial、ParNew等新生代均采用)将新生代分为伊甸区(默认占比80%)与两个复活区(默认各占10%),实际上只浪费了10%的内存。而当10%不足以放置伊甸区需要存活的对象时,需要依赖其它内存区域(大部分情况下是老年代)进行分配担保

  1. 标记—整理。标记—复制算法在对象存活率较高时就要进行较多的复制操作,且需要有足够的内存空间。

标记后,要用的对象向内存空间一端移动,然后直接清理掉边界以外的内存。实际上是一项优缺点并存的决策:如果移动存活对象,尤其老年代存活对象很多,移动并更新引用是很耗资源的操作,且操作期间会STW,但是如果用标记—清除,碎片化非常严重,后续只能通过更为复杂的内存分配器和内存访问器来解决内存分配问题。例如分区空闲分配链表。内存的访问极为频繁,这样也会增加负担。Parallel Scavenge关注吞吐量,所以使用标记—整理,而CMS关注延迟,使用标记—清除。也可以平时使用标记—清除,待碎片化很严重的时候,使用标记—整理。实际上CMS就是这样的

具体的垃圾回收器:

在这里插入图片描述

  • Seical:单线程回收,事实上是在客户端模式下默认的新生代收集器,简单高效。对于内存资源受限环境是额外消耗内存最小的,且对于单核或者核心较少的环境来说,由于没有线程交互的开销,实际效率很快
    在这里插入图片描述

  • ParNew:多线程回收,实际上是Serial收集器的多线程并行版本,且除了Serial收集器外,只有它能与CMS收集器配合工作
    在这里插入图片描述

  • Parallel Scavenge:与ParNew很相似,但是关注点与其它收集器在缩短STW不同,是达到一个可以控制的吞吐量,另外自适应策略也是它与ParNew不同的一个重要表现

什么是吞吐量: 运行用户代码时间/运行用户代码时间+运行垃圾收集时间
两个重要参数用于精准控制吞吐量:最大收集垃圾停顿时间 -XX:MaxGCPauseMills 设置吞吐量大小: -XX:GCTimeRatio,还有一个参数:-XX:+UseAdaptiveSizePolicy 一个开关 实际就是自适应策略

  • Parallel Old:Parallel Scavenge老年代版本,支持多线程。在JDK6才开始提供。在此之前,PS是一个相当尴尬的状态,因为除了SO外无它选择
    在这里插入图片描述
  • CMS:以STW最短为目标,基于标记—清除,包含以下4个步骤:
  1. 初始标记:标记下GC Roots 能直接关联到的对象,速度很快,需要STW
  2. 并发标记:从GC Roots的直接关联到的对象开始遍历整个对象图的过程,耗时较长,不需要STW
  3. 重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,比初始标记长,需要STW
  4. 并发清除:并发清除对象,由于不需要移动存活对象,所以不需要STW
    在这里插入图片描述

这里介绍一个三色标记法:
白色:表示对象尚未被垃圾收集器访问过。
灰色:表示对象本身已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过
黑色:表示对象被访问过,且这个对象的所有引用都被访问过。
用户线程并发会带来漏标、误标的问题,可以通过增量更新或原始快照的方式来解决这个问题。
增量更新:黑色对象一旦插入了指向白色对象的引用关系,就将黑色变成灰色。CMS
原始快照:无论关系删除与否,都会按照快照开始扫描那一刻的对象图快照来进行搜索 G1 Shenandoah
缺点:对处理器资源很敏感;无法清除浮动垃圾,预留内存给用户线程,预留少了会并发失败,这时启动后备预案,SO来回收老年代的垃圾;碎片化严重
清理垃圾时,用户线程还在继续,所以要预留空间给用户线程,不能等到老年代满了再开始GC。JDK5的时候是68%,JDK6 92%, -XX:CMSInitiatingOccu-pancyFraction。

  • G1:理论上分代,逻辑上不分代。将内存分为一个个大小相等的Regin区,多个Regin区合并就是Humongous区。JDK9之后成为服务端模式下默认垃圾回收器。每次回收都是Regin区大小的整数倍,具体实现是跟踪每个Regin里面垃圾堆积的价值大小,即回收所获得的空间大小以及回收所需时间的经验值,然后维护一个优先级列表,每次根据用户设定的允许停顿时间(默认200)。优先回收价值收益最大的Regin,也就是Garbage First。
    1.初始标记:仅标记GC Roots能直接关联到的对象
    2.并发标记:对堆中对象进行可达性分析
    3.最终标记:对用户线程做暂停,用于处理并发阶段结束后任然遗留下来的问题
    4.筛选回收:将需要回收的Regin中存活对象复制到新的Regin中,再回收整个旧Regin(ZGC此阶段也是并发回收的)
    G1并非存粹的追求低延迟,官方设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担起全功能收集器的重任与期望
    G1的负载与内存占用(因为每个Regin都要维护一份卡表,导致记忆集很大,可能会占用堆20%甚至更大的空间)都比CMS高
  • Shenandoah:OpenJDK 12 的新特性,相比有正宗血统的ZGC,它反而更像是G1的继任者。
    相比G1改进的点:1.回收阶段并发用户线程 2.不分代 3.摒弃记忆集,改用连接矩阵,简单理解为一个二维表格
    3个重要的工作过程:1.并发标记 2.并发回收 3.并发引用更新
  • ZGC:基于Regin内存布局,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记—整理算法的,以低延迟为首要目标的一款垃圾收集器
    Regin可以动态创建和销毁,以及动态的区域容量大小。小:2MB,存放256K的小对象。中:32MB,存放大于等于256K小于4MB的对象。大:2MB整数倍,可以动态变化,实际容量可能小于中,只会存放一个大对象
    染色指针:64位指针高18位不能寻址,ZGC将后面高4位提取出来存储4个标志信息(直接到导致了ZGC能够管理的内存空间不可以超过4TB),通过这些标志,虚拟机可以从指针中看到其引用对象的三色标记状态、是否被移动过、是否只能通过finalize方法才能访问到。主要是以下4个阶段:
    1. 并发标记:标记是在指针上进行的而不是对象上,标记阶段会更新指针中的Marked0、Marted1标志位
    2. 并发预备重分配:根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集
    3. 并发重分配:核心阶段,把重分配集中的存活对象复制到新的Region上,并未重分配集中的每个region维护一个转发表,记录从旧对象到新对象的转向关系。有了染色指针的支持,能仅从引用上就明确得知一个对象是否处于重分配集之中。如果用户线程此时并发访问量文娱重分配集中的对象,这次访问就会被内存屏障截获,再根据转发表将访问转发到新复制的对象上,并同时修正更新该引用的值,使其指向新对象。这称为指针的自愈能力(实际上当某个region内存活对象复制完毕后,这个region就立马可以分配新对象,而不需要等引用关系全部修正过来)
    4. 并发重映射:修正整个堆中指向重分配集中旧对象的所有引用。这个工作并不迫切,且由于需要遍历对象,所以巧妙的跟并发标记安排在一起,以节约开销

注意:Java体系的内存管理,本质上是解决两个问题:自动给对象分配内存,自动回收分配给对象的内存。
对象优先在伊甸分配,大对象直接进入老年代。大对象的内存分配是一个坏消息,更坏的消息是遇到一群朝生夕灭的短命大对象。原因是:分配大对象时,容易导致内存明明还有不少空间就提前触发GC,复制的开销也很大。-XX:PretenureSizeThreshold参数指定大于该值的对象直接进入老年代。长期存活的对象进入老年代,动态对象年龄判定:在存活区空间中相同年龄所有对象的大小的总和大于存活区空间的一半,则年龄大于等于该对象的可以直接进入老年代。空间分配担保:-XX:HandlePromotionFailure设置是否允许担保失败
选择合适的垃圾回收器:应用优先追求高吞吐量还是低延迟;硬件环境如何;JDK发行商是什么 版本是什么以及相关参数

3. 虚拟机性能监控、故障处理工具

常用命令:
jps 、jstat(显示虚拟机中的类加载、内存、垃圾收集、即时编译等运行时数据)
jinfo(实时查看和调整虚拟机各项参数) jmap(生成堆转储快照,还可以查询finalize执行队列,堆与方法区的详细信息)
jhat(分析堆转储文件) jstack(生成线程快照,在JDK5后,Thread类有getAllStackTrances方法用于获取虚拟机中所有线程的信息,可以用这个方法做个管理员页面)
此章节大部分是命令介绍。需要挑重点学习。

4. 调优案例分析与实战

5. 类文件结构

6. 虚拟机类加载机制

  • 类加载时机:一个类型被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证准备解析三个部分统称为连接。
    加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序暗部就按的开始。而解析阶段则不一定:它在某些情况下可以再初始化阶段之后再开始,这是为了支持java语言
  • 运行时特性绑定。
    以下6种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):
    1. 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。
    2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
    3. 当初始化类的收获,其父类还没有进行过初始化,则需要先触发其父类的初始化。
    4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类.
    5. 当使用JDK7新加入的动态语言支持时。。。xxx复杂我不会
    6. 当一个接口中定义了JDK8新加入的默认方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前进行被初始化。
  • 类加载的过程:
    1. 加载:通过一个类的全限定名来获取定义此类的二进制字节流 --> 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构 --> 在内存中生成一个代表这个类的Java.lang.Class对象,作为方法区这个类的各种数据访问入口
    2. 验证:确保class文件的字节流中包含的信息符合java虚拟机规范的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
    3. 准备:正式为类中定义的变量(静态变量,被static修饰的变量)分配内存并设置变量初始值的阶段,除final修饰外,其余变量都是零值
    4. 解析:将常量池内的符号引用替换为直接引用的过程
    5. 初始化:就是执行类构造器()方法的过程。这个方法不是程序员在java代码中直接编写的方法,而是java编译器的自动生成物
  • 双亲委派模型:站在虚拟机角度来看,只有两种类加载器。一种是启动类加载器(BootStrap ClassLoader,这个是虚拟机自身的一部分),另外一种就是其他所有的类加载器,独立存在于虚拟机外部,全部继承抽象类:java.lang.ClassLoader;但是站在开发角度考虑,java一直保持着三层类加载器、双亲委派的类加载架构
    1. 启动类加载器:加载存放在JAVA_HOME\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是JVM能够识别的(按照文件名识别,如rt.java,tools.java,名字不符合的类库实际放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器处理,那直接使用null代替即可
    2. 扩展类加载器:加载JAVA_HOME\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。由java实现,所以可以在程序中使用此加载器来加载class文件
    3. 应用程序类加载器:也成为系统类加载器。加载用户类路径(classpath)上所有的类库。如果应用程序中没有自定义过自己的类加载器,那么默认的就是这个加载器
  • 双亲委派模型工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围内没有找到所需的类)时,子加载器才会尝试自己去加载。使用这种模型一个好处就是 java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。对于java程序的稳定运行极为重要。越基础的类由越上层的加载器加载。实际上JNDI服务会破坏这个模型,使用了线程上下文类加载器;OSGi实现模块化热部署也会破坏这种模型。

7. 虚拟机字节码执行引擎

  • 运行时栈帧结构:
    jvm以方法作为最基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。存储了方法的局部变量表、操作数栈、动态链接和方法返回地址 等信息,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。一个栈帧需要分配多少内存,并不会收到程序运行期间变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
  • 局部变量表:
    一组变量值 的存储空间,用于存放方法参数和方法内部定义的局部变量。在java文件编译成为calss文件时,就在方法的code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。
    以变量槽为最小单位,一个变量槽可以存放一个32位以内的数据类型(Boolean、byte、char、short、int、float、reference–一个对象的实例引用、returnAddress–少见了),对于64位长度的long和double,会分配两个连续的变量槽,访问时自然也不允许单独访问某一个变量槽,不然会抛出异常。为了节约空间,变量槽是可以复用的。如某些变量不一定覆盖整个方法体,超过了作用域时,这个变量槽就可以交给其它变量来复用。副作用是直接影响到垃圾回收器的行为。
  • 操作数栈:后入先出,其最大深度也在编译时就被确定,每一个元素都可以是任意java数据类型。32位数据占用栈容量为1,64位占用2。当一个方法开始执行的时候,这个方法的操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容。
  • 动态链接:每个栈帧都包含一个执行运行时常量池中该栈帧所属的方法引用,持有这个引用时是为了支持方法调用过程中的动态连接。
  • 方法返回地址:方法退出分为正常退出与异常退出。实际上就是把当前栈帧出栈,因为退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈,调整PC计数器中的值以指向方法调用后面的一条指令。
  • 分派调用:相对于解析调用而言,解析调用一定是一个静态的过程,在编译期间就完全确定。分为 静态单分派,静态多分派,动态单分派和动态多分派。
    1. 静态分派:所有以来静态类型来决定方法执行版本的分派动作,都成为静态分派。最典型应用表现就是方法重载。
    2. 动态分派:与java中方法的重写有重要关联
    3. 单分派与多分派:方法的接收者与参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择。

8. 类加载及执行子系统的案例与实战

9. 前端编译与优化

10. 后端编译与优化

  • 解释器与编译器:当程序需要迅速启动和执行的时候,解释器可以立即发挥作用,省去编译时间,立即运行。当程序启动后,编译器把越来越多的代码编译成本地代码,减少解释器的中间损耗

由于即时编译器编译本地代码需要占用程序运行时间,而且想要编译优化程度高的代码,除了占用时间长以外,还需要解释器收集相关监控信息。为了达到平衡点,提出了分层编译。
Java -version 可以看到是混合模式,mixed mode; 参数 -Xint 解释模式,编译器完全不介入工作;
-Xcomp 编译模式,编译优先,但是解释器任要在编译器无法编译时介入工作。

  • 分层编译:
  1. 程序纯解释执行,并且解释器不开启性能监控
  2. 使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能
  3. 仍然使用客户端编译器执行,仅开启方法及回收次数统计等有限的性能监控功能
  4. 仍然使用客户端编译器执行,开启所有性能监控
  5. 使用服务端编译器,相比起客户端编译器,前者会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化
  • 编译对象与触发条件:被多次调用的方法,被多次执行的循环体。要知道某段代码是不是热点代码,需要进行热点探测:
  • 基于采样的热点探测:虚拟机周期性检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是热点方法
    优点:简单高效,容易获取方法调用关系(将调用堆栈展开即可)
    缺点:很难精确的确认一个方法的热度,容易受到线程阻塞或者别的外界因素的影响
  • 基于计数器的热点探测:虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果超过一定阈值,则认为是热点方法
    优点:统计结果精确严谨
    缺点:实现麻烦,需要对每个方法建立并维护计数器,而且不能直接获取到方法的调用关系

hotspot使用第二种,且通过方法计数器与回边计数器来实现。 可以通过 -XX:CompileThreshold来设定,默认客户端1500,服务端10000,且不是绝对计次,而是一段时间内的调用次数。
当超过一定时间限度,方法调用次数仍然不足以让它提交给编译器编译,那该方法的计数器会被减少一半,这称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期
-XX:-UseCounterDecay 关闭热度衰减,-XX:CounterHalfLiveTime 设置半衰周期时间,单位是秒

  • 回边计数器:统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为回边,为了触发栈上的替换编译
    服务端模式下:方法调用计数器阈值-XX:CompileThreshold * OSR比例 -XX:OnStackReplacePercentage(140) - 解释器监控比率 -XX:InterpreterProfilePercentage(33) ,最后除以100

11. Java内存模型与线程

  • Java内存模型:所有的变量(不同于java的变量,不包括局部变量与方法参数,因为是线程私有的,不会被共享)都存储在主内存(可以类比物理机的主内存),每条线程还有自己的工作内存(类比处理器高速缓存),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都是在工作内存中进行。不同线程无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
  • 缓存一致性:各个处理器在访问缓存时都要遵循一些协议,在读写时要根据协议来操作,MSI MESI MOSI 等等
  • 内存间交互操作:
    lock:锁定,作用于主内存的变量,它把一个变量标识为一条线程独占的状态
    unlock:解锁,作用于主内存的变量,释放独占状态,它释放后其它线程才能独占
    read:读取,作用于主内存的变量,把变量值从主内存传输到工作内存,以便随后的load动作使用
    load:载入,作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的变量副本中
    use:使用,作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
    assign:赋值,作用于工作内存的变量,把一个从执行引擎接受的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码执行时将会执行这个操作
    store:存储,作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
    write:写入,作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中
    read load ;store write 必须按顺序执行但不要求是连续执行,也就是中间可以插入其它指令
  • volatile关键字:保证此变量对所有线程的可见性,但不一定线程安全,因为java里面的运算操作符并非原子操作。在不符合以下两条规则的运算场景中,我们仍要加锁来保证原子性
    运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
    变量不需要于其它的状态变量共同参与不变约束
    禁止指令重排序优化,通过内存屏障实现,不能把后面的指令重排到内存屏障之前,jvm即时编译器也有指令优化,cpu执行指令也有乱序性。
  • 先行发生原则:指的是java内存模型中定义的两项操作之间的偏序关系。比如操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,影响包括修改内存中共享变量的值,发送了消息,调用了方法等。
  • java内存模型天然的先行发生关系(如果两个操作之间的关系不在此列,或者无法从下列规则推到出来,则他们没有顺序性保障,虚拟机可以对它们进行随意重排序):
    1. 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。这里说的是控制流顺序而不是程序代码顺序
    管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
    2. volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
    3. 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
    4. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。可以通过join()方法是否结束、isAlive()的返回值等手段检测线程是否已经终止执行。
    5. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过interrupt()方法监测到是否有中断发生
    6. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
    7. 传递性:如果操作A先行发生于B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论
  • java与线程:实现线程主要有三种方式
    内核线程实现:1:1实现,直接由操作系统内核调度。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程
    用户线程实现:1:N实现,狭义上的用户线程就是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成
    混合实现:N:M,用户线程还是建立在用户空间中,操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁
  • java线程调度:协同式线程调度和抢占式线程调度。
    协同式:线程自己控制自己调度执行。好处实现简单,一般没有什么线程同步问题。坏处是执行时间不可控制,如果一个线程的代码编写有问题,那么程序会一直阻塞在那里
    抢占式:每个线程将由系统来分配执行时间。可以设置线程优先级完成给某些线程多点时间,但是这个并不稳定。虚拟机里的优先级不一定能与java里的优先级对应。
  • 状态转换:6种状态,在任意一个时间点中,一个线程有且只有一种状态,并且可以通过特定的方法在不同状态之间转换。
    1. 新建:new ,创建后尚未启动的线程
    2. 运行:Runnable,包括操作系统线程状态中的Running和Ready,可能正在执行,也有可能正等待操作系统为它分匹配执行时间
    3. 无限期等待:Waiting,处于这种状态的线程不会被分配处理器执行时间,他们要等待被其它线程显式唤醒。以下方法会让线程进入无限期的等待
    没有设置timeout参数的object.wait()方法
    没有设置timeout参数的thread.join()方法
    LockSupport.park()方法
    4. 限期等待:Timed Waiting,处于这种状态的线程不会被分配处理器执行时间,不过无需被显式唤醒,在一定时间后他们会由系统自动唤醒
    Thread.sleep()方法
    设置timeout参数的object.wait()方法
    设置timeout参数的thread.join()方法
    LockSupport.parkNanos()方法
    LockSupport.parkUtil()方法
    5. 阻塞:Blocked,等待获取一个排他锁
    6. 结束:Terminal:线程已终止
  • java与协程:由于java使用的1:1内核线程模型,所以在遇到高并发时,大量的线程上下文的切换本身就会消耗很多资源。在切换线程时,操作系统需要把A的上下文数据妥善保存,再把寄存器、内存分页等恢复到B挂起时的
    状态。这种保护现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,也不是一种轻量级的操作。而改用用户线程,虽然线程上下文切换造成的资源消耗不可避免,却可以人为的优化。
    协程:靠应用自己模拟多线程,进而演化成用户线程,且由于最初多数的用户线程是被设计成协同式调度,所以称为协程,分为有栈协程跟无栈协程。无栈不谈,功能少
    协程主要优势是轻量,局限性是需要在应用层面实现的内容(调用栈、调度器这些)特别多

12. 线程安全与锁优化

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值