前言
“Write Once Run anywhere” 是得益于JVM,工作了将近一年的时间也明白了,最重要的还是思想结构和底层的实现,因为就算新技术层出不穷,它们也只不过是在锦上添花而已。
本文是我是从《深入理解Java虚拟机》总结而来,如果有什么说的不对的地方,还请各位看官指出,我还进行改正
正文
JDK,JRE,JVM三者之间的关系
JDK包含JRE,JRE包含JVM
内存溢出诊断
通过一个 VM argument进行设置 -xx: +HeapDumpOutOfMemoryError
这个命令会导出一个分析文件,需要下载一些工具对这个文件加以分析。
还可以通过JDK自带的可视化工具 console.exe 进行监控。
JVM分类
-
Sun Classical VM(已淘汰,第一台商用的java虚拟机)
解释器和编译器不能一同执行。
只能使用纯解释器的方式来执行java代码
-
Exact VM
编译器和解释器混合工作即两级及时编译器
-
Hot Spot
就是我们现在最普遍使用的虚拟机。
JAVA虚拟机内存管理
java虚拟机在执行程序的时候会把它所管理的区域划分成不同的数据区。
内存区域可以分为两个部分:
1.线程共享区
方法区
-
Java堆
-
新生代
Eden(伊甸园)
Survivor(存活区)
-
老年代
- Tenured Gen
-
2.线程独占区
虚拟机栈
本地方法栈
程序计数器
内存区域之程序计数器
是一块较小的内存空间,是一个当前线程所执行的字节码的行号指示器
字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
如果执行的是线程的java方法,计时器记录的是虚拟机字节码的指令地址,
如果执行的是native方法,那么这个计数器的值是空的(Undefined)
那么另一个问题:为什么需要用程序计数器保存执行的行号呢,是为了线程上下文切换,保证了线程不会错乱,线程只是执行操作,而并不会保存数据。
内存区域之虚拟机栈
描述的Java方法运行的内存模型
每一个方法的调用到完成都对应着栈帧在虚拟机栈中入栈和出栈的过程。
栈帧:每个方法执行,都会创建一个栈帧,伴随着这个方法的产生与完成,用于存储局部变量表(定长为32),操作数栈,动态链接(面向对象的多态性),方法出口(两种出栈:1)return 2)exception)等。
-
局部变量表:存放的是编译器,数据类型,引用类型,returnAddress类型
(Tips:这个局部变量表,指的就是我们平常说的栈)
栈的区域是固定的,当我们不断调用方法,就会不断产生栈帧进入栈中,如果超出了栈的大小,就会出现stackOverflow的异常,想象平常最容易出现的场景就是递归,如果没写好的话,无止境的递归。
内存区域之本地方法栈
本地方法栈为虚拟机执行native方法服务
而虚拟机栈为虚拟机执行java方法服务
这就是二者唯一区别的区别,在***hot spot VM中这两个区域并没有明显的区分。
之所以开辟这个区域,是为了方便和系统交互,使用java和操作系统交互,有不便之处,所以最顶层的ClassLoader采用的C++编写。
内存区域之堆
内存中最大的一块。
存放对象的实例。
垃圾收集器管理的主要区域,所以很多人称之为GC堆
如果堆内存溢出,会产生OutOfMemeory的Error
-Xmx -Xms, 这两个VM参数,可以修改堆大小
内存区域之方法区
很多人称之为永生代。
垃圾收集在这个区域比较少见。
存储虚拟机加载的类信息(类的版本,字段,方法,接口),常量,静态变量,即时编译器(JIT)编译后的代码等数据。
可能出现OutOfMemory
运行时常量池
属于方法区的一部分
存放编译时生成的字面量,以及符号引用
小例子:
String str1 = "abc";
String str2 = "abc";
String str3 = new String("abc");
在这里 a与b的地址是相同的,这收益于常量池,而c与a,b是不相同的因为new是直接在堆中开辟了一条内存空间,不受常量池影响的
直接内存大多时候也被称为堆外内存,自从 JDK 引入 NIO 后,直接内存的使用也越来越普遍。通过 native 方法可以分配堆外内存,通过 DirectByteBuffer 对象来操作。
对象创建
给对象分配内存的方式:
指针碰撞
空闲列表
具体使用哪一种方式,是由堆内存是否规整决定的,是否规整是由垃圾回收机制决定的,如果垃圾回收会把区域变得相对完整则使用指针碰撞,如果是零散的,则使用空闲列表。
对象的结构
对象的大小必须是八的整数倍。
对象结构:
-
Header (自身运行时数据,类型指针)
-
markword(自身运行时数据)
第一部分markword,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。
-
klass(类型指针)
这个区域存放的是klass类型的指针,这个指针说明了当前这个对象是哪个类的实例。
-
数组长度(只有数组对象有)
记录了数组的长度,所以我们才可以通过length调用长度
-
-
instanceData
这个区域是真实存储信息的地方,不管是从父类继承来的还是自己所有的,都需要进行记录。
padding 填充内存的作用,因为对象分配的内存必须是八的整数倍,所以如果在instanceData并没有对齐的情况下,便会填充
对象的访问定位
使用句柄:有点像是一种间接寻址的感觉在堆内存中,存在一个句柄池,引用指向了句柄池中的一块地址,然后句柄池再指向堆内存,使用这种方法最大的好处就是存储的是稳定的句柄地址。
使用指针:引用直接指向堆内存。最大的好处就是速度快,节省了一次指针定位的开销
虚拟机参数
1.-Xms 设置堆的最小值
2.-Xmx 设置堆的最大值
3.-Xss 设置栈容量
垃圾回收
打印GC的详细信息 需要在VM 参数中加入下面两个参数:-verbose:gc -xx:PrintGCDetail
如何判定对象为垃圾对象?
1.引用计数法
在对象中添加一个引用计数器,当有地方引用时+1,当引用失效-1。
但是一般并不使用,因为如果存在引用的相互依赖那么引用计数法将会失效。
2.可达性分析
从GC root 节点,向下搜索,如果一个地址对于GC root对象(包括 虚拟机栈中的局部常量表中的引用的对象,方法区中类静态属性引用的对象,方法区中常量所引用的的对象,本地方法栈中引用的对象),再也没有任何可走的路径,那么将会把在堆中的整个内存都给回首掉。
这里说的最多的字眼就是引用,Java 中一共包括四种引用方法:
-
强引用
垃圾回收器永远不会回收掉强引用的对象 -
软引用
有用但是非必须,在内存即将溢出之前如果有软引用会调用第二次GC,如果还是溢出,才会曝出异常。使用SoftReference来使用软引用 -
弱引用
非必须的引用,只能存活到下一次GC之前,无论内存是否足够,使用WeakReference 来使用弱引用。 -
虚引用
这种引用存在的目的是,当这个引用的对象被回收的时候,我们会得到一个通知,提供PhantomReference来实现虚引用。
大多数情况下,回收的都是堆区,很少收集方法区,那是因为这样做性价比很低。
如果回收方法区的话,主要回收两种:废弃常量,无用的类。
回收策略:
-
标记-清除算法
通过可达性分析算法,首先标记有哪些是需要清理的,然后再将它们进行清除。这个算法简单,但是存在的问题就是效率问题和空间问题。被标记可能十分分散,清理后,在内存里就会出现特别零散的空间,不利于日后开辟空间使用。
-
复制算法
复制算法,将堆内存划分成两份,然后操作其中的一份,当需要进行垃圾回收的时候,复制算法会讲没有被回收的实例复制到另一份中去然后,将原来的所有的(不管有没有被回收的都删除掉)删除掉,然后在另一份中进行继续操作,下次在GC的时候,就和刚刚的操作一样,简单的说就是两个区域交替的工作。这样有效的解决了标记-清除算法的效率问题。但是这个问题,造成了堆内存中有空间浪费的情况
现在大多数虚拟机新生代都是采用了这种回收策略。
多说一点,在hotspot中,新生代会有一个eden区,两个survior区,比例为8:1,每次一个eden区和一个survior区被占用,也就是说只会有一个survior区被浪费掉。
-
标记-整理算法
一般应用与老年代,因为复制算法消耗空间,可能需要内存担保。
这个算法是将不需要GC的对象移向内存的一段,然后将除了一端区域界线外的对象全部清除掉
-
分代收集算法
根据不同的内存区域(新生代,或者老年代),选择不同的GC算法。
新生代使用复制算法,而老年代时候标记-清理,或标记-整理,可以做到每一块都因地制宜。
垃圾回收器:
不同的垃圾回收器对应不同的使用场景。
垃圾收集器的不断推尘出新,其实就是一个不断缩短垃圾回收时间的过程。
-
Serial
最基本
-
单线程
这就导致了一个问题,多线程并发运行,但是需要垃圾回收了,那么所有线程都被阻塞,只有垃圾回收线程在运行,直到回收完毕,其他线程才继续运行。
这个回收器对于运行在client端是一个好的选择。
-
Parnew
-
多线程
开多个线程"打扫卫生",效率更高,多个线程共同工作的时候,还是会导致阻塞。
复制算法(新生代收集器),可以与Cms(老年代收集器)共同使用
-
-
Parallel scavenge
复制算法(新生代收集器),不可以与Cms共同使用
多线程收集器
-
达到可控制的吞吐量(吞吐量:cpu用于运行用户代码的时间与cpu消耗的总时间的比值)
吞吐量 = 执行用户代码的时间/(执行用户代码的时间 + 垃圾回收的时间)
-
Cms(Concurrent Mark sweep)
可以边扔垃圾边打扫。
使用标记-清理算法
-
工作过程
- 初始标记
- 并发标记
- 重新标记
- 并发清理
-
优点
- 并发收集
- 低停顿
-
缺点
- 占用大量CPU
- 无法清理浮动垃圾
-
G1
- 使用标记-整理算法
- 优点
- 并行和并发
- 分代收集
- 空间整合
- 工作过程
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
- 工作原理
- G1与其他的收集器在内存布局上有很大的差别,它是将内存划分成了一块一块可以不连续的region,虽然保留新生代,老年代,但是已经不在物理隔离。在后台会维护一个优先列表,每次根据允许的收集时间,回收掉价值最大的region区,所以这个收集器叫 Garbage first。
上面有一个概念,容易让人混淆,那就是并发和并行,举个例子,并行就是你去看病,医院有多个看病的医生,而并发就是有多个病人找了同一个医生。
内存分配
内存分配原则:
优先分配到Eden区,当Eden区内存不足的时候,会发生一次 Minor GC。
-
大对象直接分配到老年代,因为Eden区经常出现 Minor GC,而且采用的是复制算法,如果把大对象放在其中不方便移动,所以放在了GC不经常发生的老年代。发生在老年代的GC,我们称之为 Full GC/Major GC,而且Full GC的速度要比 Minor GC 慢上10倍以上。
那什么是大对象呢?大对象指的是在内存中需要大量的连续内存,例如说 长的字符串或者大的数组。
-
长期存活的对象分配到老年代
- 每个对象都会有一个年龄对象计数器,这个计数器会因为每一次逃过了GC就会增加1,等到长到15(默认值)的时候,便会晋升到老年代。
-
空间分配担保
- 例子:假如现在分别有2M,2M,2M,4M的对象,然后我们的Eden区设置为8M。
那么前三个会首先进入到Eden区域中,但是却发现4M对象放不进去,那么会将之前的6M移到别的空闲区域中,然后在eden中放入4M对象,这个叫做 空间分配担保
- 例子:假如现在分别有2M,2M,2M,4M的对象,然后我们的Eden区设置为8M。
-
动态对象年龄判断
- 这是什么意思呢?当对象的年龄还没到进入老年代的阀值(默认15)的情况下,也是有可能进入老年代的。那就是当survivor区中同一年龄的所有对象的大小大于survivor内存大小的一半的时候,大于或等于这个年龄的对象将至今进入老年组。
-
逃逸分析与栈上分配
-
对象的作用域仅在方法中有效,没有发生逃逸,则把对象放到栈内存中
换句话说 能使用局部变量,尽量使用局部变量
-
第四十九节:虚拟机工具
虚拟机工具:
-
JPS:java process status
-m 运行时传入的参数
-v 虚拟机传入的参数
-l 详细的类信息,或者jar包信息
Jstate:监控虚拟机的各种运行状态的,例如类装载,内存,垃圾回收,JIT编译等数据的
-
Jinfo:实时查看和调整虚拟机各项参数
- -v可以查看在启动的时候,指定的参数列表
Jmap:用于生成堆转储快照,一般称为heapdump或dump
Jhat:结合jmap生成的文件进行分析,形成可视化洁面
Jstack:生成当前时刻线程快照
HSDIS:生成JIT的反编译代码
Jconsole:代替了JPS,并且可以查看远程进程的状态。
VisualVM:多合一故障处理工具
性能调优例子
问题为将用户绩效考核信息处理为一个Excel,但是时不时的不定时间会出现卡顿。
解决思路:
优化sql ❌
监控CPU ❌
-
监控内存 ✅
- 经常Full GC ✅
根本原因为把一台tomcat的堆设置的太大,而且用户生成excel的时间比较集中,导致大对象不断的生成,导致老年代告急,所以经常Full GC,产生full gc后所有其他的工作线程被阻塞,所以导致会有时间空档期。
解决方案:在一台服务器上部署多个服务器构成集群,每个集群的堆分配4G。
后记
在今后的不断学习中,我会不断的更新这篇文章。