<<深入理解JVM>>笔记与一些体悟


这段时间这本书的第二遍已经看完了,但是很多地方模糊不清楚,总的来说,这边书收获很大,作为Java开发,这本书很有帮助,本来准备进军多线程的,因此而耽搁一段时间,总之感觉还是把这本书一些章节吃透,再去多线程

第一章 走进Java(历史与展望)

展望Java技术的未来:从目前国内形势来看,go-lang在逐渐走上台面,php也同时占有一席之地
PYPL排行榜也是一个关于编程语言流行度的参考指标,其榜单数据的排名均是根据榜单对象在 Google 上相关的搜索频率进行统计排名,原始数据来自 Google Trends,也就是说某项语言或者某款 IDE 在 Google 上搜索频率越高,表示它越受欢迎。上面这份排行是基于google搜索次数决定的



TIOBE编程社区索引是编程语言流行程度的一个指标。索引每月更新一次。评级是基于全球熟练工程师、课程和第三方供应商的数量。流行的搜索引擎,如谷歌,必应,雅虎!,维基百科,亚马逊,YouTube和百度被用来计算收视率。需要注意的是,TIOBE索引并不是关于最好的编程语言,也不是大多数代码都是用哪种语言编写的。索引可用于检查您的编程技能是否仍然是最新的,或者在开始构建新的软件系统时,对应采用何种编程语言作出战略决策

Java从当初的一次编写到处运行 ,到未来的期望无语言倾向,感觉面临着巨大的挑战,毕竟现在还是天下第一
Java的优势:
1.庞大的用户群体。
2.稳定的语言,使得项目更正规,更容易形成大型体系的工程。
缺点:
1.泛型那里很不好用,Java采用了类型擦除方式。使得使用泛型会造成大量的自动拆箱、装箱,使得泛型速度变慢。
2.启动Java虚拟机的时候,还是太长了,不如一些动态类型语言来的开发效率高,对编程人员,用户也友好。

正式进入本书:
一、无语言倾向
2018年4月,Oracle Labs新公开了一项黑科技:Graal VM,这是一个在Hotspot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台,既包括Java、Scala、Groovy等基于Java虚拟机的语言,还包括C、C++、Rust等基于LLVM的语言,同时也支持,Javascript、Python和R语言等。Graal VM 可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好地本地库文件。它的基本工作原理:将这些语言的源代码或者源代码编译后的中间格式(例如LLVM字节码)通过解释器转换为能被Graal VM接受的中间表示,例如设计一个解释器专门对LLVM输出的字节码进行转换来支持C语言。(Truffle快速构建面向一种新语言的解释器。)Graal VM才是真正意义上的与物理计算机相对应的高级语言虚拟机,理由是它与物理硬件的指令集一样,做到了只与机器特性相关而不与某种高级语言特性相关。
Graal VM相比于Hotspot 主要差异在于即时编译器,相比较起来互有胜负,但是Oracle Labs和美国大学里所做的最新即时编译技术的研究全部都迁移到基于Graal VM之上进行了,令人期待。

二、新一代即时编译器
Hotspot 虚拟机中含有两个即时编译器,分别是编译耗时短但是输出代码优化程度较低的客户端编译器(C1)以及编译耗时长但输出代码优化质量也更高的服务端编译器(C2),通常他们会在分层编译机制下与解释器互相配合来共同构成Hotspot虚拟机的执行子系统。
Graal 编译器(作为C2编译器替代者),C2时间太长了,其作者都因为太复杂而不愿意维护,且用C++编写而成。Graal编译器本身就是Java语言写成,且C2编译器代码可以轻松移植到Graal编译器上,不成熟,需要通过-XX:+UnlockExperimentalVMOptions -XX:UseJVMCICompiler参数来开启。

三、向native迈进
Java自身存在缺点,主要是近几年在从大型单体应用架构向小型微服务架构发展的技术潮流之下,Java表现的不适应。(没看过微服务所以详细写一下)在微服务架构视角下,应用拆分后,单个微服务不在需要面队数十、数百GB乃至TB的内存,有了高可用的服务集群,也无需追求单个服务7*24小时运行,随时中断和更新;但是Java的启动时间较长,需要时间才能到达最高性能,就和这些场景有点矛盾。在无服务架构下,矛盾会更大。
AppCDS 允许把加载解析的类型信息缓存起来,从而提升下次启动速度。提前编译能带来的最大好处是Java虚拟机加载这些预编译成二进制库之后能直接调用,无需等待即时编译器在运行是将其编译成二进制机器码,理论上,提前编译可以减少即时编译带来的预热时间,减少Java长期给人带来的第一次运行慢的不良体验。但是坏处也很明显,必须为不同的硬件、操作系统去编译对应的发行包;降低Java连接过程的动态性,必须要求加载的代码在编译期全部已知,而不能在运行期才确定否则只能舍弃以及编译好的,退回即时编译状态。
SubStrate VM出现,一个极小的运行时环境,包括了独立的异常处理、同步调度、线程管理、内存管理和JNI访问,目标是代替Hotspot用来支持提前编译后的程序运行,无需重复开启Java虚拟机初始化过程,不能动态加载其他编译器不可知的代码和类库。好处就是显著降低内存占用和启动时间,运行在Substrate VM上的小规模应用,其内存占用和启动时间比Hotspot下降5-50倍。

四、Java语法糖持续变多,给编程人员提供良好的体验
结束~
第一部分 自动内存管理 援引作者一句话Java与C++之间由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来

第二章 Java内存区域与内存溢出异常

1. 运行时数据区域
共包含:方法区(线程公有)、Java堆(线程公有)、Java虚拟机栈(线程私有)、本地方法栈(线程私有)、程序计数器(线程私有)、运行时常量池(方法区一部分)、直接内存(不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的区域。
1.1 程序计数器
1.字节码解释器工作时就是通过改变这个计数器的值来选取下一条执行的字节码指令,是程序控制流的指示器,线程恢复也需要依赖程序计数器。
2.Java虚拟机多线程通过线程切换、分配处理器执行时间的方式实现,一个确定的时刻一个处理器只会执行一条线程中的指令,因此每个线程各自拥有自己的程序计数器
3.如果执行Java方法则计数器记录的是正在执行的虚拟机字节码指令的地址,如果执行的是native方法,则计数器值为空
1.2 虚拟机栈
1.其线程私有、它的生命周期与线程相同;每个方法被执行时,Java虚拟机都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。
2.Java内存区域,程序员最关心两个区域堆(heap)和栈(stack),栈指的是虚拟机栈或者虚拟机栈中的局部变量表部分。
3.局部变量表存放了基本数据类型(boolean、byte、char、short、int、float、long、double)、引用类型(reference)和returnAddress类型。
4.局部变量表的存储空间以局部变量槽slot表示,long 和 double都占用2个slot,其余占用一个,局部变量表所需的内存空间在编译期就完成,在方法的运行时期不会改变局部变量表大小
5.如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverFlow异常;如果Java虚拟机栈容量可以动态扩展、当栈扩展时无法申请到足够的内存会抛出OutOfMemory异常。
1.3 本地方法栈
1.和虚拟机栈发挥的作用类似,区别就是虚拟机栈为执行Java方法服务,本地方法栈则为虚拟机栈用到的Native方法服务。
2.异常类同虚拟机栈
1.4 Java堆
1.堆中只存储对象实例。
2.Java是垃圾收集器管理的内存区域,从分配内存的角度看,所有线程共享的Java堆可以划分出多个线程私有的分配缓冲区,用来提升对象分配效率。Java堆的划分只有一个目的就是,更好的分配内存,更好的回收内存
3.堆里面不要求物理上连续存储,但逻辑上是连续的,对于大对象(典型的数组对象)很可能要求连续的内存空间。
4.如果在Java堆中没有内存完成实例分配,并且堆无法再扩展时,抛出OutOfMemoryError(OOM)。
1.5 方法区
1.用于存储已经被虚拟机加载类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
2.JDK8完全废除永久代,采用本地内存来实现方法区,使用元空间
3.这个区域内存回收的目标是:常量池的回收和对类型的卸载。(比较难实现)
4.如果方法区无法满足新的内存分配需求时,抛出OOM。
1.5 运行时常量池
1.作为方法区的一部分,常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后放在运行时常量池中。可能也会存储直接引用
2.具备动态性,Java并不要求常量一定要编译期才可以产生例如String类的intern()方法。
3.当常量池无法申请到内存时抛出OOM
1.6 直接内存
1.不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的区域。
2.JDK1.4 新加入的NIO,引入了一种基于通道channel与缓冲区BUffer的I/O方式,使用Native函数直接分配堆外内存,通过存储在Java堆里面的DirectByteBUffer对象作为这块区域的引用进行操作,避免了Java堆和Native堆中来回复制数据。
3.本机直接内存分配不受到Java堆大小的限制,但收到总内存限制,一般服务器管理员配置虚拟机参数时,忽略直接内存,使得各内存区域总和大于物理内存限制,导致OOM异常。
2. 对象的一生
Hotspot虚拟机在Java堆中对象分配、布局和访问的全过程。
2.1 对象的创建
1.当虚拟机遇到一条字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查到这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有先执行相应的类加载。
2.类加载检查通过后,给对象分配内存:
(1)指针碰撞:假设Java堆中内存是绝对规整的,所有被用过的内存放一边,空闲的内存放一边,中间放一个指针作为分界点的指示器,那所分配内存就是仅仅把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
(2)空闲列表:如果不规整,就必须维护一个空闲列表,记录哪块内存可以用,分配的时候从列表中找到足够大的一块空间分给对象实例,并更新记录。
选择哪种方式由Java堆是否规整来决定,而Java堆是否规整,又由所采用的垃圾收集器是否带有空间压缩整理决定。因此,使用Serial、Parnew等带压缩整理的收集器,采用指针碰撞;采用CMS基于清楚算法的收集器时,理论上采用空闲列表实现。
还需要考虑一个问题,对象创建是很频繁的行为,仅仅修改一个指针所指向的位置,并发情况下并不是线程安全的,可能出现给A分配内存、指针没修改,对象B又使用原来指针分配内存,解决方法:
(1)对分配内存空间进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证操作的原子性
CAS看这里CAS
(2)另外一种是把内存分配的动作按照线程划分在不同空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区分配,只有本地缓冲区用完了,分配新的缓冲区才需要同步锁定。
3.内存分配完成后,虚拟机将分配到的内存空间(但不包括对象头)都初始化为0,这步操作保证了对象的实例字段在Java代码中不赋初值就可以使用,使程序能访问到这些字段的数据类型所对应的零值。
4.接下来,对对象进行必要的设置,例如这个对象是哪个类的实例,、如何才能找到类的元数据信息、对象的哈希码、对象的GC年龄分带,存储在对象头之中
5.构造函数,即CLass中的()方法,按照程序员自己的意愿进行初始化,这样一个对象才被完整构建出来
2.2 对象的内存布局
1.对象在堆内存中可以划分为三个部分:对象头、实例数据、对齐填充
2.对象头:MarkWord 如:哈希码、GC年龄分带、锁状态标志、线程持有的锁、偏向线程ID等,另外一部分就是类型指针,即对象指向它的类型数据的指针,虚拟机需要通过这个指针来确定该对象是哪个类的实例。如果对象是Java数组,还要存储一块这个对象多大,记录长度。数组大小不确定,虚拟机无法通过元数据确定数组大小。
3.实例数据:是对象真正存储的有效信息
4.对齐填充:因为Hotspot要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象大小必须是8字节的整数倍,所以需要对齐填充。
2.3 对象的访问定位
1.主流方式使用句柄和直接指针两种
(1)使用句柄的化,Java堆中可能划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型实例数据各自具体的地址信息。
优势:reference中存储的是稳定的句柄地址,在对象移动(垃圾收集)的时候,只会改变句柄中实例数据指针,而reference本身不要修改。
(2)使用直接指针的化,Java堆中对象的内存布局就必须要考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果访问对象本身的化,就不需多一次间接访问的开销。
优势:速度快,节省一次指针定位的时间开销,(Hotspot)中就用直接指针。

3. 实战:OutOfMemoryError异常

第三章 垃圾收集器与内存分配策略

1)垃圾回收出现的原因:前面一章,分析了,JVM哪些地方会出现OOM异常,这章介绍Java垃圾收集器为了避免内存溢出异常都做了哪些努力。
2)为什么学习垃圾回收?当排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化技术”的技术实施必要的监控和调节。
3)哪些内存需要回收?程序计数器、虚拟机栈、本地方法栈随线程而生,随线程消灭,栈中的栈帧随着方法的进入和退出有条不紊的执行着入栈和出栈操作。并且每一个栈帧分配多少内存是确定下来的,大体上是编译器已知的,因此这几个区域回收都具有确定性,当方法结束或者线程结束就不需要考虑太多问题。所以回收主要面向Java堆方法区

  1. 对象已死?
    1)引用计数法:对象中添加一个引用计数器,如果引用+1,引用失效-1,任何时刻引用为零的对象就是不可能再被使用的。但是有些问题无法解决,例如循环引用问题。
    2)可达性分析算法:用GC Roots作为根对象,根据引用关系向下搜索,对象不可达,则对象不在使用。固定作为GC Roots的对象包括以下几种,
    1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中用到的参数、局部变量、临时变量。
    2.在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。
    3.方法区中常量引用的对象。
    4.本地方法栈中JNI引用的对象。
    5.基本数据类型对应的Class对象,系统类加载器。
    6.所有被同步锁(synchronized关键字)持有的对象。
    7.反应Java虚拟机内部情况地JMXBean、JVMTI中注册的回调、本地代码缓存等。
    备注:可能会有临时性加入,做局部回收的时候,某个区域内的对象完全有可能被位于堆中的其他区域引用,这时就需要将这些关联区域对象一并加入GC Roots集合中去,才能保证可达性分析的正确。
    3)四种引用关系的出现
    出现原因:当内存空间还足够时,能保留在内存中,如果内存空间再进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——系统缓存
    1.强引用:Object obj = new Object() 这种引用关系。只要强引用在,垃圾收集器就永远不会回收掉被引用对象。
    2.软引用:软引用用来描述一些还有用、非必须的对象,只要被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围进行第二次回收,SoftReference
    3.弱引用:用来描述那些非必须的对象,但是它强度比软引用更弱一点,被弱引用关联的对象只能存活到下一次垃圾收集之前,垃圾收集器开始工作,都会回收,WeakReference
    4.虚引用:一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,唯一目的就是:为了能在这个对象被收集器回收时收到一个系统通知,PhantomReference。
    4)回收方法区
    回收内容:废弃的常量和不再使用的类型
    判读类型是否不再被使用(同时满足以下条件):
    1.该类中所有实例都被回收
    2.加载该类的类加载器已经被回收,除非是精心设计过的可替换的类加载器,如OSGI,JSP等,否则很难达成
    3.该类对应的对象没有在任何地方被引用过,无法在任何地方通过反射访问该类
    备注:在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGI这类频繁自定义类加载器的场景中,通常都需要具备类型卸载的能力,以保证不会造成过大的内存压力。
  2. 垃圾收集算法
    首先分为:引用计数式垃圾收集和追踪式垃圾收集也被称为直接垃圾收集和简接垃圾收集,Java主要用追踪式垃圾收集。
    1)分代收集理论
    弱分代假说:绝大多数对象都是朝生夕灭的。
    强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
    跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
    设计原则:收集器应该将Java堆划分出不同的区域,然后回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程那么把它们集中放在仪器,每次回收只关注如何保留少量存活等。Java堆分区之后,才有了Minor GC、Major GC、Full GC这样的回收类型的划分才会有按照存亡特征相匹配的算法,“标记-复制、标记-清除、标记-整理算法。但是有很大的困难,例如跨代引用。针对跨代引用,完全扫描不合适,在新生代建立一个记忆集,Remembered Set,这个结构把老年代划分成若干小块,引用的小块里的对象才会被加入到GC Roots进行扫描,虽然赋值时会增加开销,但是划算的。
    2)标记清除算法(三种算法比较了解,不做过多赘述)
    3)标记复制算法
    4)标记整理算法(老年代)
    备注:是否移动回收后的存活对象是一项优缺点并存的风险:如果移动对象,老年代这种大量对象存活,移动就是一种比较复杂的过程,移动对象时必须暂停用户线程,stop the world,但是如果按照标记清除那样子考虑,就会产生空间碎片问题,所以移动对象与否都会有问题,移动内存回收会更复杂,不移动则内存分配时更复杂,从停顿时间来看,不移动停顿时间更短,但从吞吐量上来看,移动会划算,因为内存分配和访问垃圾收集频率比要高的多,这部分耗时增加,总吞吐量下降。如果关注吞吐量,Parallel Scavenge收集器基于整理算法,关注延迟的则基于清除算法CMS。
  3. HotSpot的算法细节实现
    1)根节点枚举
    1.查找能作为GC Roots的引用,迄今为止所有收集器在根节点枚举这一步都必须暂停用户线程,根结点枚举始终必须在一个能保障一致性的快照中才得以进行,一致性就是在某个时间点停下来,原因是不暂停程序,根节点集合的对象引用关系还在不断变化。
    2.算法产生原因:当用户线程暂停下来之后,其实并不需要一个不漏的检查上下文和全局引用的位置,虚拟机通过OopMap的数据结构来知道直接哪些地方存着对象引用。
    3.当虚拟机加载完成时,即时编译过程中,也会在特定的位置记录下栈里和寄存器哪些位置是引用,所以直接扫描。
    2)安全点
    1.原因:导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将需要大量的额外存储空间。
    2.在特定位置记录了这些信息,这些位置被称为安全点,也就是决定了用户程序执行时并非在代码指令流的任意位置都能停顿下来开始垃圾收集,而强制要求到达安全点开始收集。
    3.选定安全点的标准是,是否具有让程序长时间执行的特征。最明显的特征是指令序列的复用,例如方法调用、循环跳转、异常跳转等。所以只有这些功能的指令才会产生安全点。
    问题:如何让垃圾收集器发生时让所有线程(不包括JNI——native)都跑到最近的安全点,然后停顿下来。
    方案:抢先式中断和主动式中断
    抢先式中断:不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它跑到安全点上再中断。
    主动式中断:当垃圾收集器需要中断线程时,不直接对线程操作,仅仅简单在未来设置一个标志位,各个线程执行过程中会不停主动去轮询(轮询(Polling)是一种CPU决策如何提供周边设备服务的方式。轮询法的概念是:由CPU定时发出询问,依序询问每一个周边设备是否需要其服务,有即给予服务,服务结束后再问下一个周边,接着不断周而复始。)这个标志,一旦发现中断标志为真就自己在最近的安全点上主动中断挂起。(Hotspot实现)
    3)安全区域
    1.原因:当程序不执行的时候,不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep和Blocked状态,这时用户线程就无法响应虚拟机的中断请求,所以引入“安全区域”。
    2.安全区域是指能够确保在某一段代码片段中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。当用户线程执行到安全区域里面的代码时,首先标识自己进入安全区域,当这段时间里虚拟机要发起垃圾收集时就不必去管这些已经声明自己在安全区域内的线程了。当离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者其他需要暂停用户线程的行为),如果完成了,那线程就当没事发生过,继续执行否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
    4)记忆集与卡表
    1.原因:解决对象跨代引用所带来的问题。
    2.垃圾收集器在新生代建立记忆集,以避免把整个老年代加入GC Roots扫描范围
    3.记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
    4.列举一些可供选择的记录精度:
    字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如32位/64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
    对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
    卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
    第三种“卡精度”所指的就是用一种称为“卡表”的方式去实现记忆集。它定义了记忆集的记录精度、与堆内存的映射关系等。卡表最简单的形式可以只是一个字节数组,字节数组的每一个元素都对应着其标识的内存区域中的一块特定大小的内存块,这个内存块被称作“卡页”。卡页的大小都是以2的N次幂的字节数,例如Hotspot中卡页是2的9次幂,512字节
    一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或者更多的对象字段存在跨代指针,那就将对应卡表的数组元素的值标识为1,称为元素变脏,没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素就能轻易得出哪些卡页内存块包含跨代指针,把他们加入GC Roots中一并扫描。
    5)写屏障/伪共享
    1.原因:卡表元素如何维护问题,例如他们何时变脏、谁来把他们变脏。
    2.有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,时间点应该发生在引用类型字段赋值的那一刻。
    3.Hotspot虚拟机通过写屏障技术维护卡表状态,写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,写后屏障和写前屏障。
    伪共享:高并发场景下产生,伪共享是处理并发底层细节时,一种经常要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
    解决方法:不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,不过会增加额外的开销,但能够避免伪共享问题,两者各有性能损耗。
    6)并发可达性分析/增量更新/原始快照
    1.垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活,可达性分析算法理论上全过程都基于一个能保障一致性的快照才能分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于GC Roots相比起整个Java堆中全部对象毕竟还是少数,且在优化技巧(OopMap)的加持下,非常短暂了。但是从GC Roots 再继续往下遍历对象图,这一步骤的停顿时间就必然和Java堆容量直接成正比例关系了。
    2.标记阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,必须削减这部分停顿时间。先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?
    白色:对象尚未被垃圾收集器访问过,显然在刚开始的阶段,所有对象都是白色的,若分析结束的阶段,仍然是白色的对象,即代表不可达。
    黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。
    灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没被扫描过。
    如果不暂停用户线程,可能会导致收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图结构,可能出现两种后果:一种是把原本消亡的对象错误标记为存活,只不过产生了一点逃过本次收集的浮动垃圾而已。第二种是把原本存活的对象错误标记为已消亡,这是致命的后果。
    Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生对象消失问题,即原本应该是黑色的对象被误标为白色:
    1.赋值器插入了一条或多条从黑色对象到白色对象的新引用;
    2.赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
    只需要破坏一个条件,就可以避免对象消失,产生两种解决方案:增量更新和原始快照
    增量更新:当黑色对象插入新的白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
    原始快照:当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
    备注:无论是对引用关系的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。CMS是基于增量更新来做并发标记的、G1和Shenandoah则是用原始快照来实现。
  4. 几种经典的垃圾收集器
    吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
    1)Serial
    1.一个单线程收集器,启用时必须 stop the world,采取标记-复制算法实现。
    2.迄今为止,它仍然是Hotspot虚拟机运行再客户端模式下的默认新生代收集器,他是所有收集器里额外内存消耗最小的,且没有线程交互的开销,专心做垃圾收集。
    3.在桌面场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般不会特别大,收集几十兆甚至一百兆的新生代,垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多100毫秒以内,所以Serial对于运行在客户端模式下的虚拟机来说是一个很好的选择。
    2)ParNew
    1.实质上是Serial的多线程并行版本,新生代采用复制算法,多线程并行,暂停用户线程。
    2.只能和CMS搭配使用,在服务端常用,无法与Parallel Scavenge搭配使用,因为一个面向低延迟一个面向高吞吐量,除此之外就是,Parallel没有分代框架,而CMS又是基于这种强分代框架下。
    3)CMS
    1.CMS收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法,其中包含几个阶段:
    初始标记:需要stop the world 。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;
    并发标记:就是从GC Roots的直接关联对象开始遍历整个对象图的过程,虽然耗时较长但是不需要停顿用户线程;
    重新标记:需要stop the world ,则是为了修正并发标记期间,因用户线程线程继续运转而导致的标记变动的记录;
    并发清除:清理掉标记阶段已经死亡的对象,由于不需要移动存活对象,所以也是并发清除。
    2.缺点:
    ①:CMS对处理器资源非常敏感,因占用一部分线程而导致程序变慢,降低总吞吐量。
    ②:CMS收集器无法处理浮动垃圾,有可能导致Concurrent Mode Failure失败进而导致另一次完全的Stop the world 的Full GC产生。由于由于垃圾收集阶段用户线程还在持续运行,那还需要预留足够的内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全填满了再进行收集,必须预留一部分空间供并发收集时的程序运行,JDK6时,CMS收集器启动阈值已经默认提升至92%,但又会面临另外一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次并发失败,这时虚拟机不得不启动后备预案:冻结用户线程,临时启动Serial Old 收集器来重新进行老年代的垃圾收集。
    ③:产生大量碎片空间,空间碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC时开启内存碎片的合并整理过程,且必须移动存活对象,是无法并发的。
    4)G1
    1.G1是里程碑式的收集器,弱化分代概念,开创了面向局部收集和基于Region的内存布局形式,主要面向服务端应用。
    2.设计者们希望能建立起“停顿时间模型”的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
    3.G1面向堆内存任何部分来组成回收集,进行回收,衡量标准不再是它属于哪一个分代,而是哪块内存存放的垃圾数量最多,回收收益最大,这就是G1的Mixed GC模式。
    4.G1把连续的Java堆划分成多个大小相等的独立区域(Region),每一个Region根据需要扮演新生代Eden空间、Survivor空间或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样就能达到很好的收集效果。
    5.Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1认为超过了一个Region大小的一半即可判定为大对象,每个Region取值范围是1~32MB,为2的N次幂。对于超过了整个Region容量的超大对象,将会被存放在N个连续的Humongous Region中,G1的大多数行为都把Humongous Region作为老年代的一部分开看待。
    6.每次垃圾收集根据用户设定的允许的收集停顿时间,优先处理回收价值最大的那部分Region。
    7.G1收集器面临的问题:
    ①:多个Region的跨代引用问题,每个Region都会维护自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并且标记这些指针分别在哪个范围之内,本质是G1记忆集是一个hash表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种双向卡表结构(卡表是我指向谁,这个结构还有谁指向我),所以维护起来,就有着更高的内存占用负担,G1至少要消耗大约相当于Java堆容量10%到20%的额外内存来维持收集器工作。
    ②:并发标记阶段如何保证收集线程与用户线程互不干扰的运行。CMS采用增量更新的算法实现,而G1采用原始快照算法(SATB)实现。此外垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续由新对象被创建,G1为每一个Region设计了两个名为TAMS的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的。如果内存回收速度赶不上内存分配速度,G1收集器也要被迫冻结用户线程,导致Stop the world。Full GC。
    ③:如何建立起可靠的停顿预测模型?用户通过参数指定的停顿时间只意味着垃圾收集发生之前的期望值,G1收集器的停顿时间预测模型是以衰减均值为理论基础来实现的,在垃圾收集的过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里脏卡数量等各个可测量的步骤花费成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
    8.运作步骤:
    初始标记:仅仅只是标记一下GC Root能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象,需要停顿线程。
    并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里地对象图,当扫描完成以后,还需要重新处理SATB记录下地在并发时有引用变动的对象。
    最终标记:对用户线程做另外一个短暂的暂停,用于处理并发阶段结束后遗留下来的最后那少量的SATB记录。
    筛选回收:负责更新Region统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划;可以自由选择任意个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间,这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
    由此可看到,G1除了并发标记之外,其余阶段也是要暂停用户线程的。并非纯粹追求低延迟,官方的设计目标是在延迟可控的情况下,尽可能获得高的吞吐量。
    9.用户指定期望的停顿时间是G1很强大的一个功能。通常设定100~300ms。
    10.从G1开始最先进的垃圾收集器的设计导向都不约而同地变为能够应付应用地内存分配速率,而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集器的速度能跟得上对象分配的速度,那一切就能运作得很完美
    11.CMS与G1收集器的对比
    G1优点:指定最大停顿时间、分region的内存布局、按收益动态确定回收集。G1从整体上基于标记整理算法,局部(两个Region之间)看又是基于标记复制算法,不会产生内存空间碎片,收集完成后能提供规整的可用内存。
    G1相比于CMS缺点:在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都比CMS高。G1的记忆集可能会占整个堆容量的20%乃至更多的内存空间;从执行负载的角度来看,CMS用写后屏障更新维护卡表,而G1除了使用写后屏障来维护卡表之外,为了实现原始快照算法(SATB),还要使用写前屏障来跟踪并发时的指针变化情况。(相比较增量更新算法,原始快照算法能减少并发标记和重新标记阶段的消耗,避免CMS在最终标记阶段停顿时间过长的缺点,但会产生额外的负担。CMS写屏障是同步操作,而G1就不得不将其实现为类似消息队列的结构,把写前屏障和写后屏障要做的事情放在消息队列里,然后再异步处理。
    结论:小内存上CMS好一点,大内存G1上大多能发挥其优势,6GB到8GB之间。
  5. low_delay垃圾收集器
    1)ZGC
    1.ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现的标记-整理算法,以低延迟为首要目标的一款垃圾收集器。
    2.ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小:
    小型Region:容量固定2MB,用于放置小于256KB的小对象。
    中型Region:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
    大型Region:容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB以上的大对象。每个大型的Region中只会存放一个大对象,虽然名字是大型Region但最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配,因为复制一个大对象的代价极高。
    3.ZGC的核心问题——并发整理算法
    ZGC标志性设计——染色指针技术:如果我们要在对象上存储一些额外的、只供收集器、或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等就是这样存储的。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外的负担。但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢?能不能从指针或者与对象内存无关的地方得到这些信息——追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景。例如对象标记阶段过程需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关。
  6. 实战:内存分配与回收策略

第二部分 虚拟机执行子系统 代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

第六章 类文件结构

这里就不做整理了,第一次看书时就打开了一个编译后的.class文件查看了,具体位置等,传一张照片。



  1. Class类文件结构
  2. 字节码指令简介
  3. 公有设计,私有实现

第七章 虚拟机类加载机制(鉴于字数过多,从第七章开始分小文章整理)

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
与那些在编译时需要连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一点额外的开销,但却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期间动态加载和动态链接实现的。

  1. 类加载的时机
  2. 类加载的过程
  3. 类加载器
  4. Java模块化系统

第八章 虚拟机字节码执行引擎

  1. 运行时栈帧结构
  2. 方法调用
  3. 动态类型语言支持
  4. 基于栈的字节码解释执行引擎

第九章 类加载及执行子系统的案例与实战

  1. TomCat
  2. OSGI
  3. 字节码生成技术与动态代理技术
  4. Backport工具:Java的时光

前后端编译 这里就只看了,泛型 自动拆箱装箱技术

第十章 前端编译与优化

1.泛型、自动拆箱、装箱与foreach循环

第十一章 后端编译与优化

  1. 即时编译器
  2. 提前编译器
  3. 编译器优化技术
  4. 深入理解Graal编译器

你可能感兴趣的:(<<深入理解JVM>>笔记与一些体悟)