JVM?看这一篇还不够吗

312 阅读20分钟

JVM?看这一篇还不够吗

写在前面

随着我们程序员‘入坑’的时间越来越长,不可避免地遇到JVM方面的问题,比如各种OOM异常、GC次数过多、每次GC时间很长等等问题。这时候,不掌握JVM知识,遇到GC问题,一脸懵逼啊,还怎么去调优!同时,JVM知识在出去找工作面试时,几乎也是被面试官必问的知识点,所以,不掌握这玩意,被面试官问到,又是一脸懵逼啊。本章就是专门用来详细介绍JVM的,尽力简单高效的分享知识。

内容简介

一 JAVA内存模型的介绍
二 JVM 内部组成
三 JAVA对象内存布局介绍和创建过程
四 JVM 参数调优
五 垃圾回收机制详解
六 JVM性能分析命令

一 Java内存模型

1 定义:

 描述的是一组规范,定义了变量的访问方式.

2 特性

  •  可见性:  当一个线程对共享变量做了修改后其他线程可以立即感知到该共享变量的改变
    
  •  原子性:  是不可分割,当某个线程在做某个业务时,要么同时成功,要么失败,中间不能被加塞或分割。
    
  •  有序性:  计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排。
    

3 Volatile关键字

3.1 特性:

  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排序优化。(保证了JMM中的有序性)

若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

在虚拟机层面,为了尽可能减少内存操作速度远慢于CPU运行速度所带来的CPU空置的影响,会修改代码的执行顺序。

3.2 原理

  • 当写一个volatile变量时,把该线程对应的本地内存中的共享变量刷新到主内存。
  • 当读一个volatile变量时,线程接下来将从主内存中读取共享变量。

3.3 如何解决原子性问题?

  • 使用JUC包下原子类,比如AtomicInteger
  • 加锁synchronized

二 JVM内部组成

1 类加载器子系统

类加载器主要分类有:

引导类加载器:使用C/C++编写的,加载Java的核心类库,并不继承ClassLoader,没有父加载器
拓展类加载器:加载 jre/lib/ext  目录下的类库
应用程序加载器:加载用户自定义类的默认加载器
用户自定义类加载器:
    为何要自定义类加载器?
        隔离加载类,避免类冲突
        扩展加载源
        防止源码泄漏
    自定义步骤:
        继承ClassLoader
        也可以继承URLClassLoader

类加载过程:

1 加载

 通过一个类的全限定名来获取其定义的二进制字节流
 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

2 链接

 验证:确保被加载类的正确性,但不是必须的,它对程序运行期没有影响
     分为如下几个阶段:
         文件格式验证:验证字节流是否符合Class文件格式的规范,例如:是否以0xCAFEBABE开头
         元数据验证:对字节码描述的信息进行语义分析,证其描述的信息符合Java语言规范的要求
         字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
         符号引用验证:确保解析动作能正确执行。
 准备:为类变量分配内存并且设置该类变量的默认初始值,即零值
     注意:这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中
 解析:
     将常量池中的符号引用替换为直接引用(内存地址)的过程
     解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
     

符号引用:就是一组符号来描述目标,可以是任何字面量。 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

3 初始化

只有对类的主动使用才会导致类的初始化,就是执行类构造器方法<clinit> 的过程
    类的主动使用包括以下六种:  
    – 创建类的实例,也就是new的方式

    – 访问某个类或接口的静态变量,或者对该静态变量赋值

    – 调用类的静态方法

    – 反射(如Class.forName(“com.shengsiyuan.Test”))

    – 初始化某个类的子类,则其父类也会被初始化

    – Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
    

2 运行时数据区

栈:

生命周期:主管程序运行,在线程创建是创建,跟随线程的生命周期,线程结束栈内存释放。
不存在垃圾回收,并且线程私有。

运行原理:
     栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集。
     当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈…… 依次执行完毕后,先弹出后进......F3栈帧,再弹出F2栈帧,再弹出F1栈帧。

栈帧内部结构:

    局部变量表
        保存方法的局部变量(8种基本数据类型/对象的引用地址)
动态链接
        指向运行时常量池的方法引用
操作数栈
        主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
        底层是数组实现
方法返回地址
一些附加信息

堆:

内部结构

新生代:占整个堆内存的1/3
        伊甸园区:占整个新生代8/10,当该区域满了的时候,会发生YGC/MinorGC
                
        幸存者区:该区域满时,不会发生GC,当伊甸园区满了才会在该区域发生GC
                幸存0区:占整个新生代1/10
                        
                幸存者1区:占整个新生代1/10
                

老年代:占整个堆内存的2/3
        当对象经历过15次GC后仍然可用,就会进入该区域
        主要存放长生命周期的对象
        发生在该区域的GC叫做MajorGC

方法区(jdk1.8叫元空间)

内部存储这些数据:

运行时常量池
方法信息
Class类信息
域信息(类属性)
注意
	使用本地内存
	虚拟机规范中没有明说是否要回收,也可能会存在类卸载
	线程共享

本地方法栈

管理本地方法的调用

程序计数器

每个线程都有一个程序计算器,就是一个指针,指向方法区中的方法字节码(下一个将要执行的指令代码),由执行引擎读取下一条指令.
不会GC,不会OOM
因为CPU需要不停的切换各个线程,切换回来后需要知道从哪开始执行

3 执行引擎

任务:将字节码指令解释/编译为对应平台上的本地机器指令

4 本地方法接口

native 修饰的方法,由C/C++ 实现

三 JAVA对象内存布局和创建过程

1 内存布局

一 对象头
    包含两部分:
        1 运行时数据
            Hash值
            GC分代年龄
            锁状态标志
            线程持有的锁
            偏向线程ID
        2 类型指针:指向类元数据
 
二 实例数据
        包括从父类继承下来的字段和自身的

2 创建过程:

1 加载类元信息
        判断对应的类是否加载/链接/初始化
2 为对象分配内存
        若内存连续
                采用指针碰撞的方式分配
        若内存不连续
                使用空闲列表记录那些空闲的内存
3 处理并发问题
        采用CAS失败重试/区域加锁保证原子性
4 属性的默认初始化(零值初始化)
5 设置对象头信息
6 属性的显示初始化/代码块初始化/

四 JVM 参数调优

1 参数分类

1 标配参数
比如:java    -version
2 XX参数
    boolean类型
        公式: -XX: + 或者 - 某个属性 。+表示开启,-表示关闭。
        比如:-XX:+PrintGCDetails
    KV类型
        公式:-XX:key=value
        比如:-XX:MetaspaceSize=21m

2 常用参数

-Xms :初始内存大小,默认物理内存的1/64
    等价于 -XX:InititalHeapSize

-Xmx : 最大分配内存,默认为物理内存1/4
    等价于 -XX:MaxHeapSize

-Xss : 设置单个线程栈的大小,默认值和平台有关,linux一般为1024k
    等价于 -XX:ThreadStackSize

-Xmn: 设置年轻代大小

-XX:MetaspaceSize :
     设置元空间大小,元空间使用的本地内存。
     
-XX:+PrintGCDetails :打印垃圾回收信息:
     
-XX:SurvivorRatio :	设置新生代eden和s0/s1空间的比例,默认是8:1:1
    该值设置多少就是设置eden区的比例是多少,s0/s1相同。

-XX:NewRatio: 设置年轻代和老年代的比例。默认是2,即新生代:老年代=1:2
    
-XX:MaxTenuringThreshold :
     设置进入老年代最大年龄,默认为15,即对象需要经过15次GC才能进入老年代。

结合SpringBoot:Java -server jvm各种参数 -jar jar/war包名称

五 垃圾回收

1 如何判断对象是否被回收?

引用计数算法:
    给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1Java 语言中没有选用引用计数算法,原因是它很难解决对象之间的相互循环引用的问题。解释:当AB两个对象没有其他对象引用时,但AB相互引用着对方,计数器无法为0,于是无法回收AB两个对象。

根搜索算法:
    通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。
    哪些可以作为GC Roots 对象?
        1、栈中引用的对象。
        2、方法区中类静态属性引用的对象。
        3、方法区中常量引用的对象。 比如,使用static final 修饰的对象。
        4、本地方法栈中引用的对象。
        5.同步锁sync持有的对象

2 引用关系

强引用:只要强引用关系还存在,永远不会被JVM回收

软引用:只有当内存不够的时候,JVM会对软引用的对象回收
        
弱引用:JVM只要有GC都会回收该引用对象
        
虚引用:PhantomReference  任何时候都可能会被GC回收。必须和引用队列联合使用,不能单独使用。
        该引用的对象在GC后会放入ReferenceQueue引用队列中。可以在该对象销毁后做些业务。
        唯一目的:就是在这个对象被收集器回收时收到一个系统通知          

3 GC算法

1 复制算法:
    将内存空间分为两块,每次只使用其中一块,
    将正在使用的内存中的存活对象复制到未被使用的内存块中,
    之后清除正在使用的内存块中所有对象,交换两个内存的角色,最后完成垃圾回收.
    
    优点:
        运行高效
        可以保证空间的连续性,不会出现内存碎片
    缺点:需要2倍的内存空间
    场景:
        用在新生代。
        复制的存活对象越少,性能越高
        

2 标记/清除
    标记阶段:
        从引用根节点(GC  Roots)开始遍历,标记所有被引用的对象,一般在对象头中标记为可达对象.
    清除阶段:
        对堆中所有对象进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则回收。
        
    缺点:
        容易导致内存碎片,需要维护一个空闲列表
        效率不高
        进行GC的时候,需要停止整个应用程序

3 标记/压缩
    标记阶段:
        从引用根节点(GC  Roots)开始遍历,标记所有被引用的对象,一般在对象头中标记为可达对象.
    压缩阶段:
        将所有存活对象压缩到内存的一端,按顺序排放,然后清理边界外的空间.
        
    总结:相当于在 标记/清除算法执行完成后,再进行一次的内存整理
    缺点
        效率上要低于复制算法
        移动过程中需要暂停用户线程,STW
    场景:老年代
4 分区算法
    把整个堆空间划分成连续不同的小区间,每个小区间独立使用,独立回收
    

JVM采用的分代收集算法 原因:因为不同对象的生命周期是不一样的

新生代:采用了GC的复制算法,因为新生代一般是新对象,对象存活率低。

老年代:采用标清或标整。因为老年代中因为对象存活率高。

4 GC时可能发生的错误:

有两大类:

内存溢出:
    1StackOverflowError:
    2OutOfMemoryError:java heap space
    3OutOfMemoryError:GC overhead limit exceeded
        服务器资源大量时间被用来GC,且GC效果很差。
    4OutOfMemoryError:Direct buffer memory
        分配堆外内存不够用,即超过-XX:MaxDirectMemorySize的值。
        一般在NIO程序中会报这种异常。 
    5OutOfMemoryError:unable to create new native thread
        原因是一个应用进程中创建了过多的线程。可以设置linux允许单个进程可以运行的最大线程数。
    6OutOfMemoryError:Metaspace:
    
    解决办法:Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
    
内存泄漏:对象不会再被程序使用,但是GC又不能回收。
    有可能在单例模式,数据库连接,网络连接中,对象没法被回收。

STW: (Stop-The-World) 是在执行垃圾收集算法时,应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起。 所有GC都会存在这个事件. JVM在后台会自动发起和自动完成的.

Minor GC和Full GC的区别

Minor GC:又称新生代GC,指发生在新生代的垃圾收集动作;
    因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;

Full GC:又称Major GC或老年代GC,指发生在老年代的GC;
    Major GC速度一般比Minor GC慢10倍以上;

5 垃圾回收器

分类:

1 Serial :串行垃圾回收器,只有一个线程进行垃圾回收,会暂停用户线程,直到完成。
    开启命令:-XX:+UseSerialGC
    应用场景:
        主要用于Client模式;
        而在Server模式有两大用途:
         1 在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
         2 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
    具体分类:
        Serial GC:发生在新生代,采用复制算法。
        Serial Old GC:用于老年代,采用标记/压缩算法

s.jpg

2 Parallel: 并行垃圾回收器,多个垃圾收集器线程并行工作,java8默认
    开启命令:-XX:+UseParrallelGC
    具体分类:
        1 ParNew:用于新生代,老年代可以使用CMS
            开启命令:
                    "-XX:+UseConcMarkSweepGC":指定使用CMS后,会默认使用ParNew作为新生代收集器;
                    "-XX:+UseParNewGC":强制指定使用ParNew;
            特点:除了多线程外,其余的行为、特点和Serial收集器一样;
        2 Parallel Scavenge:因为与吞吐量关系密切,也称为吞吐量收集器
            特点:有一些特点与ParNew收集器相似
                新生代收集器;
                采用复制算法;
                多线程收集;
            应用场景:
                高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;
                当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,
                即程序主要在后台进行计算,而不需要与用户进行太多交互;
                例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;
                

p.jpg

3 CMS:并发垃圾回收器
    目的:减少STW的时间
    开启命令:-XX:+UseConMarkSweepGC 
    特点:
        针对老年代
        基于"标记-清除"算法(不进行压缩操作,产生内存碎片);            
        以获取最短回收停顿时间为目标;
        并发收集、低停顿;
    应用场景:
          与用户交互较多的场景;        
          希望系统停顿时间最短,注重服务的响应速度;
          以给用户带来较好的体验;
    回收步骤:
        1 初始标记(CMS-initial-mark) 
            会导致STW; 初始标记阶段就是标记老年代中的GC ROOT对象和与GC ROOT对象关联的对象给标记出来。
        2 并发标记(CMS-concurrent-mark)
            从GC Roots的直接关联对象开始遍历,这个过程耗时较长,但是不需要暂停用户线程
        3 重新标记(CMS-remark) 
            为了修正并发标记期间,因为用户线程继续运作和导致标记产生变动的那一部分对象的标记记录,也会导致STW
        4 并发清除(CMS-concurrent-sweep)
            清理标记阶段被判断为垃圾的对象,可以与用户线程同时并发执行
            

20170102225017372.jpg

    为何不使用标记压缩算法?
        因为当并发清除时,在整理内存过程中,用户线程还在运行,对象的内存地址不能修改。
        
    优点:并发收集
          低延迟
    缺点:
        1 会产生内存碎片
        2 无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败
            浮动垃圾(Floating Garbage):在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;
            这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;            
            如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;     
        3 对CPU资源非常敏感,CMS的默认收集线程数量是=(CPU数量+3)/4;当cpu数量不足4个时,对系统影响较大。



4 G1:Garbage-First收集器:
    是JDK7-u4才推出商用的收集器;
    宏观上它不在区分年轻代和老年代,把内存划分为2048个独立的子区域。
        小范围内区分年轻代和老年代。最大的好处是避免了全内存扫描。
    设计目标取代CMS收集器。
    特点:
        1 并行与并发
             能充分利用多CPU、多核环境下的硬件优势;
             可以并行来缩短"Stop The World"停顿时间;
             也可以并发让垃圾收集与用户程序同时进行;

        2 分代收集,收集范围包括新生代和老年代    
              能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;
              能够采用不同方式处理不同时期的对象;
        3 结合多种垃圾收集算法,空间整合,不产生碎片
            从整体看,是基于标记-整理算法;
            从局部(两个Region间)看,是基于复制算法;
        4 可预测的停顿:低停顿的同时实现高吞吐量
            G1除了追求低停顿处,还能建立可预测的停顿时间模型;
            可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;
    采用的算法:Region之间是复制算法	
    命令:
        -XX:+UseG1GC:开启
        "-XX:MaxGCPauseMillis":为G1设置暂停时间目标,默认值为200毫秒;
    优点:
        用户可以指定停顿时间:
            使用-XX:MaxGCPauseMills  最大GC停顿时间。JVM尽可能做到。
        并行与并发兼具
        不会产生内存碎片
    使用场景:
        面向服务端应用,针对具有大内存、多处理器的机器;
        最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
        大内存,多处理器的机器上
        在下面的情况时,使用G1可能比CMS好:
            超过50%的Java堆被活动数据占用;
            对象分配频率或年代提升频率变化很大;
            GC停顿时间过长(长于0.51秒)。
        是否一定采用G1呢?也未必:
            如果现在采用的收集器没有出现问题,不用急着去选择G1;
            如果应用程序追求低停顿,可以尝试选择G1;
            是否代替CMS需要实际场景测试才知道。

20170102225017799.jpg

吞吐量与收集器关注点说明

吞吐量(Throughput)

      CPU用于运行用户代码的时间与CPU总消耗时间的比值;

      即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间);    

      高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间;

垃圾收集器期望的目标(关注点)

1 停顿时间    

      停顿时间越短就适合需要与用户交互的程序;

      良好的响应速度能提升用户体验;

2 吞吐量

      高吞吐量则可以高效率地利用CPU时间,尽快完成运算的任务;

      主要适合在后台计算而不需要太多交互的任务;

3 覆盖区(Footprint)

      在达到前面两个目标的情况下,尽量减少堆的内存空间;

      可以获得更好的空间局部性;

六 性能分析

常用命令:

1 Jps: 列出系S统中所有java应用程序。
    Jps  -l :输出主函数的完整路径。
    
2 Jinfo:查看或修改虚拟机参数
    语法:jinfo  <option>  <pid>
    例如:jinfo  -flag PrintGCDetails  pid
            查看打印GC日志是否开启。
    也可以动态修改参数:
     Jinfo  -flag  +PrintGCDetails  pid
     
3 Jmap:内存映像工具
    它可以生成Java 程序的堆dump文件,还可以查看堆内对象实例的统计信息。
    
    生成当前java程序的堆快照:
    语法:
        Jmap  -dump:format=b,file=dump文件地址  <javaPid>
            生成dump文件后,可以使用jhat、Visual VM、MAT等工具分析。
        Jmap  -histo  pid  >  s.txt
            统计java程序的对象信息: 
            将pid的java程序的对象统计信息输出到s.txt中。
    
4 Jstat:用于观察Java应用程序运行时信息,查看堆的详细信息。
    例如:输出GC相关堆信息:jstat  -gc或 -gcutil  pid
5 Jstack :用于导出Java应用程序的线程堆栈。
    语法:Jstack  [-l]  <pid>
        可以帮助开发人员找到死锁问题。
        
6 Jvisualvm:Java Visual VM  可视化工具

7 Jconsole:监控堆信息、类加载情况,线程监控,还可以检测死锁。

8 Jcmd:	JDK1.7之后新增的命令行工具。它可以导出堆,查看java进程、导出线程信息、执行GC等。
    语法:
    Jcmd :查看java进程
    Jcmd  <pid>  Thread.print:打印线程栈信息
    Jcmd  <pid>  GC.heap_dump  dump文件地址:导出堆信息,供Mat等分析
    Jcmd  <pid>  help : 查看支持哪些命令

未完待续。。。