深入理解JVM

jdk、jre、jvm的关系

https://docs.oracle.com/javase/8/docs/

jdk包含jre,jre里面含有jvm

image.png

JVM内部组成结构

image.png

jvm总共由三部分组成:类装载子系统、运行时数据区、字节码执行引擎

java代码通过javac命令编译成class字节码文件,然后通过java命令运行class字节码文件,运行时jvm虚拟机就开始工作,首先会将字节码文件通过类装载器加载到内存区域中,即运行时数据区,然后通过执行引擎来加载java程序的代码

调优主要在运行时数据区进行

类加载器

通过javac将.java文件编译成.class字节码文件后,需要将.class文件加载到jvm中运行,此过程需要通过类加载器加载。而类加载器又分为几种:

类加载器类型

Bootstrap ClassLoader(启动类加载器):该类加载器由C++实现的,负责加载java基础类,对应加载的文件是%JRE_HOME%/lib/目录下的rt.jar、resources.jar、charsets.jar和class等

Extension ClassLoader(标准扩展类加载器):继承URLClassLoader。对应加载的文件是%JRE_HOME%/lib/ext目录下的jar和class等

App ClassLoader(系统类加载器):继承URLClassLoader。对应加载的应用程序classpath目录下的所有jar和class等

Custom ClassLoader(用户自定义类加载器):由java实现。可以自定义类加载器,并可以加载指定路径下的class文件

双亲委派机制

双亲委派机制是当类加载器需要加载某一个.class字节码文件时,则首先会把这个任务委托给它的上级类加载器,然后递归这个操作,如果上级没有加载该.class文件,自己才会去加载这个.class文件

image.png

目的是为了防止加载同一个.class文件:通过委托去询问上级是否已经在加载过该.class,如果加载过了,则不需要重新加载,保证了数据安全。同时保证核心.class文件不被褚篡改:通过委托的方式,保证核心.class文件不被篡改,即使被篡改也不会被加载,即使被加载也不会是同一个class对象,因为不同的加载器加载同一个.class也不是同一个class对象。这样则保证了class的执行安全

运行时数据区

栈(虚拟机栈,即线程)

主要存放局部变量,即线程中生成的变量,除了局部变量之外,还有操作数栈(存一些javap反编译后的一些操作,比如存放数据,加减乘除等)、动态链接(jvm->c++、c,将符号引用转变为直接引用,通过动态链接找到方法区中具体的代码方法)、方法出口(将方法信息返回)

当一个线程被创建时,就会在栈中分配该线程的内存空间,即栈帧

问题:

为什么使用栈来分配内存空间:因为在程序运行期间,一个方法在调用另外一个方法的时候,先执行的方法会先分配空间,后执行的后分,但后分的方法会先结束,释放内存,造成一个先进后出的现象,就跟栈的原理一样

new出来的对象一般都放在堆里

年轻代1/3、老年代2/3

年轻代中又分3份,分别占8:1:1,其中eden占8,s0、s1各占1

新生成的对象优先放到eden去中,jvm会通过执行引擎生成垃圾回收线程minor gc,回收内存区域中的垃圾对象,非垃圾的对象会放到s0区,满后放到s1,然后在这两个去来回复制清楚,每次来回分代年龄都会+1,分代年龄达到15后还没被回收就会放到老年代,当老年代慢之后,jvm会启动full gc回收全部区域的垃圾对象

分代年龄

对象的组成中对象头的组成之一

可达性分析算法

GC ROOT:线程栈的本地变量、静态变量、本地方法栈的变量等等

凡是GC ROOT引用的都是非垃圾

程序计数器

用来记java程序正要执行一行代码的行号,实际是操作数栈在方法区中的指令的偏移地址

每个线程都有自己的程序计数器,在线程创建时,就被一同创建

为什么要设计程序计数器:

当一条正在运行的线程,被优先级更高的线程抢占,不得不挂起等待优先级高的线程结束,结束后我们需要程序计数器来帮我们定位原先挂起的线程执行到哪一行代码了,让jvm知道从哪行开始执行

程序计数器,保存的是当前执行的字节码的偏移地址(也就是之前说的行号,其实那不是行号,是指令的偏移地址,只是为了好理解,才说是行号的,),当执行到下一条指令的时候,改变的只是程序计数器中保存的地址,并不需要申请新的内存来保存新的指令地址;因此,永远都不可能内存溢出的;因此,jvm虚拟机规范,也就没有规定,也是唯一一个没有规定 OutOfMemoryError 异常 的区域; ;

当线程执行的是本地方法的时候,程序计数器中保存的值是空(undefined);原因很简单:本地方法是C++/C 写的,由系统调用,根本不会产生字节码文件,因此,程序计数器也就不会做任何记录 ;

方法区

jdk8之前叫永久代,之后叫元空间

放常量,静态变量,类信息

本地方法栈

存放执行的本地方法,native修饰的方法,使用的c++或c写的

字节码执行引擎

内存溢出场景

堆内存满了(OOM:outofmemoryError)

添加参数-XX:+HeapDumpOnOutOfmemoryError生成堆存储快照,并使用内存分析工具进行分析

alibaba的调优工具Arthas

官网下载jar包,使用java -jar就可运行

以前使用jstack命令去找出占用cpu最高的线程堆栈信息,使用Arthas执行thread id号就可以找到问题代码

jad命令将正在运行的程序反编译

jvm调优

目的

减少FULL GC

stop the word 简称stw,在执行gc垃圾回收线程的过程中会停掉用户线程

为什么stw会停掉用户线程,jvm开发人员还要开发stw呢?

假如没有ste机制,用户线程进来后,当执行gc过程中,gcroot引用的对象会被标记为非垃圾,但当用户线程结束后,用户线程的资源会被释放掉,gcroot标记的非垃圾对象此时已经是垃圾对象了,jvm不得不重新进行可达性分析,这样会很耗时间

案列1

当年轻代中eden区满后进行minor gc将对象放入 s0区,此时如果对象总大小大于s0区大小,则直接放入到老年代或者当对象分代年龄大于15的时候,也会放入老年代,除此之外jvm还有对象动态年龄判断机制,就是对象总大小大于s0大小的百分之五十,也会直接放入到老年代,如果每过几十秒都有对象进入老年代,就会频繁触发full gc

解决

将年轻代大小调高

案例2

假如每秒几十万的并发量,每个线程产生几百兆的对象,如果我们调大堆大小到几十个G,如果eden去满后触发minor gc,回对这几十G的对象进行回收,会浪费几秒甚至几分钟的时间,在此阶段还有用户线程请求进来,每次请求可能会造成请求超时

解决

使用垃圾收集器

例如G1:在eden区达到一定存储量后触发一次gc操作,回收一部分,这样的处理就不会占用太长时间,底层机制设置了最大停顿时间

垃圾回收机制

回收的时机一般分为:CPU空闲,内存不足,内存使用极限
垃圾回收机制的缺点,优点,特点,小记
缺点,
无法精确控制垃圾回收的时机和顺序,虚拟机需要跟踪所有对象,确定有用和无用的对象,花费处理器时间,
优点:
不需要花太多时间 解决 存储器问题,缩短开发时间;安全性完整性,GC是一套完整机制
特点,
只能回收无用对象的内存空间,对于物理资源,如数据库链接, 磁盘IO流,网络链接等,等物理类型资源无法释放,需要手动释放处理。

垃圾回收算法

复制

将内存按容量划分为两块,每次只使用其中一块。当这一块内存用完了,就将存活的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。这样使得每次都是对半个内存区回收,也不用考虑内存碎片问题,简单高效。缺点需要两倍的内存空间。
image.png
应用场景:回收新生代;如Serial收集器、ParNew收集器、Parallel Scavenge收集器、G1(从局部看)

标记-清除

GC分为两个阶段,标记和清除。首先标记所有可回收的对象,在标记完成后统一回收所有被标记的对象。同时会产生不连续的内存碎片。碎片过多会导致以后程序运行时需要分配较大对象时,无法找到足够的连续内存,而不得已再次触发GC。
image.png
应用场景:针对老年代的CMS收集器

标记-整理

标记-整理算法是根据老年代的特点应运而生。
也分为两个阶段,首先标记可回收的对象,再将存活的对象都向一端移动,然后清理掉边界以外的内存。此方法避免标记-清除算法的碎片问题,同时也避免了复制算法的空间问题。
image.png
应用场景:很多垃圾收集器采用这种算法来回收老年代,如Serial Old收集器、G1(从整体看)

垃圾收集器

每种垃圾收集器在不同的场景下可能使用不同的垃圾收集器,因为每种垃圾收集器的stw过程不一样

Serial 收集器

串行收集器,它是最早诞生的垃圾回收器,以单线程的方式进行垃圾收集,在JVM刚出来的情况下,计算机内存与现在相比特别地小,即便是串行回收,它的速度依然很快。
image.png
特点:单线程、简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
应用场景:小内存、单核CPU情况下的垃圾收集

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)
image.png
特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
和Serial收集器一样存在Stop The World问题
应用场景:ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。

Parallel Scavenge 收集器

收集与吞吐量关系密切,故也称为吞吐量优先收集器。
特点:属于新生代收集器也是采用复制算法的收集器,又是并行的多线程收集器(与ParNew收集器类似)。
该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)
GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。
Parallel Scavenge收集器使用两个参数控制吞吐量:
XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
XX:GCRatio 直接设置吞吐量的大小。

Serial Old 收集器

Serial Old是Serial收集器的老年代版本。
特点:同样是单线程收集器,采用标记-整理算法。
应用场景:主要也是使用在Client模式下的虚拟机中。也可在Server模式下使用。
它在Server模式下主要的两大用途:

  • 在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备方案,在并发收集Concurent Mode Failure时使用。

    Parallel Old 收集器

    它是Parallel Scavenge收集器的老年代版本。
    image.png
    特点:多线程,采用标记-整理算法。
    应用场景:注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old (PS + PO,JDK1.8默认) 收集器。

    CMS 收集器

    一种以获取最短回收停顿时间为目标的收集器,用于老年代的垃圾回收
    image.png
    特点:基于标记—清除算法实现,与用户线程并发收集、并发清除,低停顿、低延时
    应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。
    CMS收集器的运行过程可以大致分为以下四个阶段
    初始标记
    标记老年代中的所有GC Roots对象
    标记年轻代中活着的对象引用到老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象)
    并发标记
    进行GC Roots Tracing 的过程,找出存活对象且与用户线程可并发执行。
    从“初始标记”阶段标记的对象开始找出所有存活的对象
    因为是并发执行,在用户线程运行的时候,会发生新生代对象晋升到老年代、或者是更新老年代对象的引用关系等等,对于这些新生成或改变的引用关系,可能会存在漏标,所有就必须要进行下一阶段的“重新标记”,为了提高下了阶段重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,下一阶段只需扫描这些Dirty Card的对象,避免扫描整个老年代;
    并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理。
    由于这个阶段是和用户线程并发执行的,可能会导致concurrent mode failure
    重新标记
    为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题。
    由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻带的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。
    另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled,提高reMark效率
    并发清理
    对标记的对象进行清除回收。
    通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。
    这个阶段主要是清除那些没有标记的对象并且回收空间;
    由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

    G1 收集器

    G1是一款面向服务端应用的垃圾收集器
    它是在CMS的基础上改进而来,现已被JDK1.9作为默认的垃圾回收器
    image.png
    特点如下:
    并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
    分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
    空间整合:G1运作期间不会产生空间碎片,收集后能提供规整的可用内存。
    可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。、
    G1为什么能建立可预测的停顿时间模型?
    因为它有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这样就保证了在有限的时间内可以获取尽可能高的收集效率。
    G1与其他收集器的区别:
    其他收集器的工作范围是整个新生代或者老年代、G1收集器的工作范围是整个Java堆。在使用G1收集器时,它将整个Java堆划分为多个大小相等的独立区域(Region)。虽然也保留了新生代、老年代的概念,但新生代和老年代不再是相互隔离的,他们都是一部分Region(不需要连续)的集合。
    G1收集器存在的问题:
    Region不可能是孤立的,分配在Region中的对象可以与Java堆中的任意对象发生引用关系。在采用可达性分析算法来判断对象是否存活时,得扫描整个Java堆才能保证准确性。其他收集器也存在这种问题(G1更加突出而已)。会导致Minor GC效率下降。
    G1收集器是如何解决上述问题的?
    采用Remembered Set来避免整堆扫描。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用对象是否处于多个Region中(即检查老年代中是否引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆进行扫描也不会有遗漏。
    如果不计算维护 Remembered Set 的操作,G1收集器大致可分为如下步骤:
    初始标记
    仅标记GC Roots能直接到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。(需要线程停顿,但耗时很短。)
    并发标记
    从GC Roots开始对堆中对象进行可达性分析,找出存活对象。(耗时较长,但可与用户程序并发执行)
    最终标记
    为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。且对象的变化记录在线程Remembered Set Logs里面,把Remembered Set Logs里面的数据合并到Remembered Set中。(需要线程停顿,但可并行执行。)
    筛选回收
    对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。(可并发执行)

    JMM(java内存模型)与内存屏障

image.png

每次创建一个线程jmm都会为其分配独立的工作内存,线程实际操作的变量都是主内存中共享变量的副本,操作完之后再刷新回主内存中

java内存模型和计算机内存模型有很多相似之处,只是java内存模型屏蔽了不同的操作系统和底层硬件之间内存访问差异,实现了在各个平台都能达到一致的内存访问效果。

JVM启动之后,操作系统会为JVM进程分配一定的内存空间,这部分内存空间就称为“主内存”。

另外Java程序的所有工作都由线程来完成,而每个线程都会有一小块内存,称为“工作内存”, Java中的线程在执行的过程中,会先将数据从主内存中复制到线程的工作内存,然后再执行计算,执行计算之后,再把计算结果刷新到“主内存”中。

CPU内存模型

image.png

每个CPU自带一个高速缓冲区,在运行的时候,会将需要运行的数据从计算机内先复制到cpu的高速缓冲区中,然后cpu在基于高速缓冲区的数据进行运算,运算结束之后,再将高速缓冲区的数据刷新到主内存中,这样cpu的执行指令的速度就可以大大提升

volatile

三大特性:可见性、不保证原子性、禁止指令重排(有序性)

可见性

底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存

IA-32和Intel 64架构软件卡发者手册对lock指令的解释:

  1. 会将当前处理器缓存行的数据立即写回到系统内存
  2. 这个写回内存的操作会引起在其他cpu里缓存了该内存地址的数据无效(EMSi协议)
  3. 提供内存屏障功能,使lock前后指令不能重排序

有序性

原子操作

read(读取):从主内存中读取数据

load(载入):将主内存读取到的数据写入到工作内存

use(使用):从工作内存读取数据来计算

assigin(赋值):将计算好的值重新赋值到工作内存中

store(存储):将工作内存数据写入到主内存

write(写入):将store过去的变量值赋值给主内存中的变量

lock(锁定):将主内存变量加锁,标识为线程独占状态

unlock(解锁):将主内存变量解锁,解锁后其他贤臣可以锁定该变量

image.png

JMM缓存不一致问题

缓存一致性协议(MESi)

多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效

image.png

缓存加锁

缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA-32和Intel 64处理器使用MESI实现缓存一致性协议