JVM体系
JVM的位置
JVM是运行在操作系统之上的,它与硬件没有直接的交互
整体结构
程序计数器
是一个非常小的内存空间,几乎可以忽略不记。
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码,通过改变计数器的值来读取指令。
在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。
CPU中的程序计数器和JVM中的PC
CPU中的程序计数器(PC) CPU中的PC是一个大小为一个字的存储设备(寄存器),在任何时候,PC中存储的都是内存地址(是不是有点像指针?),而CPU就根据PC中的内存地址,到相应的内存取出指令然后执行并且更新PC的值。在计算机通电后这个过程会一直不断的反复进行。计算机的核心也在于此。 JAVA运行时数据区域程序计数器 在CPU中PC是一个物理设备,而java中PC则是一个一块比较小的内存空间,它是当前线程字节码执行的行号指示器。在java的概念模型中,字节码解释器就是通过改变这个计数器中的值来选取下一条执行的字节码指令的,它的程序控制流的指示器,分支,线程恢复等功能都依赖于这个计数器。 我们知道多线程的实现是多个线程轮流占用CPU而实现的,而在线程切换的时候就需要保存当前线程的执行状态,这样在这个线程重新占用CPU的时候才能恢复到之前的状态,而在JVM状态的保存是依赖于PC实现的,所以PC是线程所私有的内存区域,这个区域也是java运行时数据区域唯一不会发生OOM的区域
栈
栈内存,主管程序的运行,生命周期和线程同步;
线程结束,栈内存也就释放,对于栈来说,不存在垃圾回收问题。一旦线程结束,栈就over
Java 虚拟机栈是由⼀个个栈帧组成,⽽每个栈帧中都拥有:局部变量表、操作数栈、动态链接、⽅法出⼝信息。
栈里有:8大基本类型+对象引用+实例的方法
程序正在执行的方法,一定在栈的顶部
会产生两类异常:
StackOverflowError:当线程请求的栈深度超过了虚拟机允许的最大深度时抛出。 OutOfMemoryError:如果 JVM 栈容量可以动态扩展,虚拟机栈占用内存超出抛出。
本地方法栈
为虚拟机使⽤到的 Native ⽅法服务
Native
凡是带了native关键字的,说明Java的作用范围达不到了,会去调用底层C语言的库!
会进入本地方法栈
调用本地方法接口 JNI
JNI作用:拓展Java的使用,融合不同的编程语言为Java所用!(Java诞生时,C、C++横行,必须要有能调用他们的程序)
它在内存区域中专门开辟了一块标记区域:本地方法栈,登记native方法
最终执行的时候,通过JNI加载本地方法库中的方法。
Java程序驱动打印机,管理系统时会用到(现在),在企业级应用中较为少见!
堆和栈 有什么区别?
(1)申请方式 stack:由系统自动分配,声明在函数中一个局部变量int b; 系统自动在栈中为b开辟空间 heap:需要程序员自己申请,并指明大小。手动new Object() (2)申请后系统的响应 stack:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出 heap:操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。 (3)申请大小的限制 stack:栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M,如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小。 heap:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。 (4)申请效率的比较 stack:由系统自动分配,速度较快。但程序员是无法控制的。 heap:由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。 (5)heap和stack中的存储内容 stack:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,注意静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。 heap:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。
方法区
方法区是被所有线程共享,静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池存在方法区中,但是实例变量存在堆内存中。
static、final、Class模板,常量池
永久代
永久代是 HotSpot 的概念,⽅法区是 Java 虚拟机规范中的定义,是⼀种规范,⽽永久代是⼀种实现
jdk1.6之前:永久代,常量池在方法区; jdk1.7:永久代,但是慢慢退化了,去永久代,常量池在堆中 jdk1.8之后:⽅法区的实现从永久代变成了元空间,常量池在元空间,元空间使用的是直接内存
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
1.整个永久代由JVM本身设置固定⼤⼩上限,⽆法进⾏调整,⽽元空间使⽤的是直接内存,是由操作系统来管理的,受本机可⽤内存的限制,溢出的⼏率会更⼩。 2.元空间⾥⾯存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了,⽽由系统的实际可⽤空间来控制,这样能加载的类就更多了。 直接内存是在运行时数据区外的、直接向系统申请的内存空间。 通常访问直接内存的速度会优于Java堆。
常量池
JVM常量池主要分为Class文件常量池、运行时常量池,全局字符串常量池,以及基本类型包装类对象常量池。
运行时常量池存放常量池表,用于存放编译器生成的各种字面量与符号引用。一般除了保存 Class 文件 中描述的符号引用外,还会把符号引用翻译的直接引用也存储在运行时常量池。除此之外,也会存放字符串基本类型。
常量池的好处 常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。 例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。 (1)节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。 (2)节省运行时间:比较字符串时,==比equals()快。对于两个引用变量,只用判断引用是否相等,也就可以判断实际值是否相等。
直接内存
直接内存也称为堆外内存,就是把内存对象分配在JVM堆外的内存区域。这部分内存不是虚拟机管理, 而是由操作系统来管理。 Java通过DriectByteBuffer对其进行操作,避免了在 Java 堆和 Native堆来回复制数据。
堆
一个JVM只有一个堆内存,堆是JVM管理的内存中最大的一块,堆内存的大小可以调节的。
此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在堆⾥分配内存。
堆内存中还要细分为三个区域:新生代、老年代、永久代(Jdk7,8之后移除)
GC垃圾回收,主要是在伊甸园区和老年代
新生区
一个伊甸区,两个幸存区(s0,s1)
所占堆空间大小:
新生代跟老年代是1:2,而新生代中的三个分区中分别是8:1:1。
-
新生代:发生的GC叫做轻GC也叫MinorGC,所用的算法叫做复制算法。
-
老年代:发生的GC叫做重GC也叫Full GC,所用的算法叫做标记清除算法和标记压缩算法
基本上发生了一次Major GC 就会发生一次 Minor GC。并且Major GC 的速度往往会比 Minor GC 慢 10 倍。
JVM内存分配与回收
Java 的⾃动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java ⾃动内存管理 最核⼼的功能是堆内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域
⼤部分情况,对象都会⾸先在Eden区域分配,达到伊甸区存放对象的阈值后,伊甸区就开始进行垃圾回收,也就是我们常说的轻GC,将大部分不再使用的对象Kill掉。在这次垃圾回收后,如果对象还存活,则会进⼊幸存者0区s0(From),并且对象的年龄还会加1(Eden区->Survivor 区后对象的初始年龄变为1); 在某一时刻伊甸区又达到了一定的阈值,再次进行gc,这时候就会将伊甸区和幸存者0区存活下来的对象迁移到幸存者1区(To)。经过这次 GC 后,Eden区和"From"区已经被清空。这个时候,"From"和"To"会交换他们的⻆⾊,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To"。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC(轻gc)会⼀直重复这样的过程,直到“To”区被填满,"To"区被填满之后,会将所有对象移动到老年代中。 当一个对象经历了15次GC还存活,就会被晋升到老年代中。 老年代也会发生垃圾回收,就是重GC;当重GC都不能解决养老区内存满d,会报OOM堆内存溢出,程序异常停止,所有对象都消亡。
堆内存中对象的分配策略
1.对象优先在eden区分配
对象在新⽣代中 eden 区分配。当 eden 区没有⾜够空间进⾏分配时,虚拟机将发起⼀次轻GC。
2.大对象直接进入老年代
⼤对象就是需要⼤量连续内存空间的对象(⽐如:字符串、数组),为了避免为⼤对象分配内存时由于分配担保机制带来的复制⽽降低效率。(因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。)
3.长期存活的对象进入老年代
Java对象实例化的过程
1.类加载检查 虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,先执行相应的类加载过程。 2.分配内存 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后就可以确定,把一块确定大小的内存从Java堆中划分出来,分配方式有“指针碰撞”和“空闲列表”两种,选择哪种分配⽅式由 Java 堆是否规整决定(是否有内存碎片)
内存分配并发问题:
在创建对象的时候有⼀个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很 频繁的事情,作为虚拟机来说,必须要保证线程是安全的。 虚拟机采⽤两种⽅式来保证线程安全: CAS+失败重试:CAS(比较并重试)是乐观锁的一种实现方式,每次不加锁⽽是假设没有冲突⽽去完成某项操作,如果冲突失败就重试,直到成功为⽌虚拟机采⽤CAS配上失败重试的⽅式保证更新操作的原⼦性。 TLAB(线程本地分配缓冲区):为每个线程预先在伊甸区分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用CAS的方式。
3.初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型对应的零值。
4.设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存放在对象头中。
5.执行init方法
在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从 Java 程序的视⻆来看,对象创建才刚开始,init⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说, 执⾏ new 指令之后会接着执⾏init⽅法,把对象进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
对象的访问定位有哪两种⽅式?
Java程序通过栈上的reference数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有使用句柄和直接指针两种。(对于HotSpot虚拟机来说,使用的就是直接指针访问的方式。)
使用句柄:在堆中划分出一块内存作为句柄池,对象引用中存储的是句柄地址,句柄中包含了对象实例数据与类型数据(方法区)各自的具体地址信息。
直接指针:对象引用中存储的直接是对象的地址
使⽤句柄来访问的最⼤好处是对象引用中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,⽽ reference 本身不需要修改。使⽤直接指针访问⽅式最⼤的好处就是速度快,它节省了⼀次指针定位的时间开销。
如何判断对象是否死亡?
堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡
1.引用计数法
给对象中添加⼀个引⽤计数器,每当有⼀个地⽅引⽤它,计数器就加1;当引⽤失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使⽤的。
缺点:1.比较消耗内存,因为运用了计数器,每次都要计数所以比较消耗。
2.一个重大缺陷是不能处理循环引用。如果采用引用计数法,两个互相循环引用的对象将不能被回收,因为他们的引用计数无法为零。
2.可达性分析算法
基本思想就是通过⼀系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所⾛过的路径称为引⽤链,当⼀个对象到 GC Roots 没有任何引⽤链相连的话,则证明此对象是不可⽤的。可以被垃圾回收
GCroot
JVM在进行垃圾回收的时候,需要找到“垃圾”对象,也就是没有引用的对象。直接找比较耗时,所以反过来,先找正常对象,就从某些“根”开始查找,根据这些root的引用路径找到正常对象。
GCroot的特征:它只会引用其他对象,不会被其他对象引用。
在java中可以作为GC Roots的对象有以下几种: 虚拟机栈中引用的对象; 方法区类静态属性引用的变量; 方法区常量池引用的对象; 本地方法栈JNI引用的对象;
强引⽤,软引⽤,弱引⽤,虚引⽤
强引用:使⽤的⼤部分引⽤实际上都是强引⽤,new的对象。垃圾回收器不会回收。Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题。
(在强引用对象用完时需要将强引用弱化,可将对象置为空obj=null
)
软引用:有用但不是必须,只有在内存不足的时候JVM才会回收该对象。软引⽤这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。
软引用在实际中有重要的应用,例如浏览器的后退按钮,这个后退时显示的网页内容可以重新进行请求或者从缓存中取出: (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建 (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出这时候就可以使用软引用
弱引⽤:可有可⽆,弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过, 由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。
弱引用还可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
应用场景:JVM使用虚引用让Key指向ThreadLocal,为什么会这么设计呢? 因为如果使用强引用,可能会产生内存泄漏(tl生命周期结束之后,ThreadLocal本应该被回收,但是如果线程没结束(服务器中很多线程是永远不结束的,比如线程池中的线程会复用),Map里面装的东西就结束不掉,总会有这个Key指向ThreadLocal,经年累月会产生内存占用过多的问题)
虚引用:跟没有一样,虚引⽤并不会影响对象的⽣命周期。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。
应用场景:虚引用主要用来跟踪对象被垃圾回收的活动。管理堆外内存
一个对象指向堆外内存,GC是无法将这个堆外内存检测出来并干掉。那么如何管理堆外内存?将指向堆外内存的对象用虚引用指向它,这个引用被回收的时候会被让进队列里。于是,需要进行 1.在JVM堆里面把它干掉。2.在堆外内存把它回收掉。
虚引⽤必须和引⽤队列联合使⽤。当垃圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前, 把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收。程序如果发现某个虚引⽤已经被加⼊到引⽤队列,那么就可以在所引⽤的对象的内存被回收之前采取必要的⾏动。
在程序设计中⼀般很少使⽤弱引⽤与虚引⽤,使⽤软引⽤的情况较多,这是因为软引⽤可以加速JVM对垃圾内存的回收速度,可以维护系统的运⾏安全,防⽌内存溢出 (OutOfMemory)等问题的产⽣。
利用软引用和弱引用解决OOM问题:假如有一个应用需要读取大量的本地图片,如果每次读取图片都从硬盘读取,则会严重影响性能,但是如果全部加载到内存当中,又有可能造成内存溢出,此时使用软引用可以解决这个问题。 设计思路是:用一个HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题。
如何判断⼀个常量是废弃常量?
运⾏时常量池主要回收的是废弃的常量。
假如在常量池中存在字符串 "abc",如果当前没有任何String对象引⽤该字符串常量的话,就说明 常量 "abc" 就是废弃常量,如果这时发⽣内存回收的话⽽且有必要的话,"abc" 就会被系统清理 出常量池。
如何判断⼀个类是⽆⽤的类?
方法区主要回收的是无用的类
类需要同时满⾜下⾯ 3 个条件才能算是 “⽆⽤的类”
1.该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
2.加载该类的 ClassLoader 已经被回收。
3.该类对应的 java.lang.Class 对象没有在任何地⽅被引⽤,⽆法在任何地⽅通过反射访问该类的⽅法。
虚拟机可以对满⾜上述 3 个条件的⽆⽤类进⾏回收,是“可以”并不是和对象⼀样不使⽤了就会必然被回收
Java中内存泄漏
内存泄漏是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成内存空间的浪费称为内存泄漏。
原因:在开发的过程中,由于代码的实现不同就会出现很多种内存泄漏问题,让gc 系统误以为此对象还在引用中,无法回收,造成内存泄漏。
情况:
1.资源未关闭造成的内存泄漏
各种连接,如数据库连接、网络连接和IO连接等,文件读写
2.ThreadLocal用在线程池中。
3.全局缓存持有的对象不使用的时候没有及时移除,导致一直在内存中无法移除
-
静态集合类
如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。生命周期长的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。
5 堆外内存无法回收
堆外内存不受gc的管理,可能因为第三方的bug出现内存泄漏
内存泄漏的解决办法
1.尽量减少使用静态变量,或者使用完及时 赋值为null。 2.明确内存对象的有效作用域,尽量缩小对象的作用域,能用局部变量处理的不用成员变量,因为局部变量弹栈会自动回收; 3.减少长生命周期的对象持有短生命周期的引用; 4.使用StringBuilder和StringBuffer进行字符串连接,Sting和StringBuilder以及StringBuffer等都可以代表字符串,其中String字符串代表的是不可变的字符串,后两者表示可变的字符串。如果使用多个String对象进行字符串连接运算,在运行时可能产生大量临时字符串,这些字符串会保存在内存中从而导致程序性能下降。 5.对于不需要使用的对象手动设置null值,不管GC何时会开始清理,我们都应及时的将无用的对象标记为可被清理的对象; 6.各种连接(数据库连接,网络连接,IO连接)操作,务必显示调用close关闭。
垃圾回收的原理
垃圾回收器通常是作为一个单独的低级别的线程运行,在不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收。
System.gc()
垃圾回收器不可以马上回收内存,但是程序员可以手动执行System.gc(),通知GC运行,但是Java语言规范并不保证GC一定会执行。
执行System.gc()函数的作用只是提醒或告诉虚拟机,希望进行一次垃圾回收。至于什么时候进行回收还是取决于虚拟机,而且也不能保证一定进行回收。一般是在内存容量不够的时候才会触发。
源码:当直接调用System.gc()只会把这次gc请求记录下来,等到runFinalization=true的时候才会先去执行GC;发现当调用runFinalization()的时候justRunFinalization变为true
System.gc()与System.runFinalization()区别: 前面已经介绍,System.gc()表示需要虚拟机有执行FULL GC的意愿,但虚拟机不一定会立即执行,需等待合适时机; 调用System.runFinalization()方法,强制调用已经失去引用对象的finalize方法。经过可达性分析无法到达的对象即为已经失去引用的对象。因此调用System.runFinalization()方法后,会首先执行等待被GC对象的finalize方法。
谈对 OOM 的认识?如何排查
除了程序计数器,其他内存区域都有 OOM 的风险。
栈一般经常会发生StackOverflowError,比如32位的windows系统单进程限制2G内存;无限 创建线程就会发生栈的OOM; Java 8常量池移到堆中,溢出会报java.lang.OutOfMemoryError: Java heap space,设置最大元空间大小参数无效; 堆内存溢出,报错同上,这种比较好理解,GC之后还是无法在堆中申请内存创建对象就会报错; 方法区OOM,经常会遇到的是动态生成大量的类、jsp 等; 直接内存OOM,涉及到 -XX:MaxDirectMemorySize 参数she'zhi。程序可能一直在运行没有做过full gc,然后导致直接内存用光
堆这⾥最容易出现的就是 OutOfMemoryError 错误
默认情况下JVM分配的总内存是电脑内存的1/4,而初始化的内存:1/64
如何排查:
1.尝试扩大堆内存看结果
2.分析内存,看一下哪个地方出了问题(专业工具)
Jprofiler作用:
分析Dump内存文件,快速定位内存泄漏;获得堆中的数据;获得大的对象,分析大对象的占用情况
JVM 调优的命令
jps:显示指定系统内所有的HotSpot虚拟机进程 jstat:用于监控虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。 jmap:用于生成heap dump文件,还可以查询finalize执行队列、Java堆和永久代的详细信息,如当前使用率、当前使用的是哪种收集器等。如果不使用这个命令,还可以使用-XX:+HeapDumpOnOutOfMemoryError参数来让虚拟机出现OOM的时候·自动生成dump文 件。 jhat:是与jmap搭配使用,用来分析jmap生成的dump。jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析。 jstack:jstack用于生成java虚拟机当前时刻的线程快照。jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。 如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程序是如何崩溃和在程序何处发生问题。
项目中如何排查JVM问题
分析--推理--实践--总结--定位到具体问题
对于正在运行的系统:(进行监控)
1.可以通过jmap来查看JVM中各个区域的使用情况。
2.可以通过jstack来查看线程的运行清况,比如那些线程阻塞,是否出现死锁。
3.可以通过jstat查看垃圾回收的情况,特别是fullgc(重gc),(Full GC是清理整个堆空间)如果出现比较频繁,就要进行调优。
如果频繁发生full gc但又一直没有出现内存溢出,表示fullgc实际回收了很多对象,应该让这些对象大部分在年轻代gc的过程就被回收,避免进入老年代。考虑这些存活时间不长的对象是不是比较大,年轻代放不下,直接进入了老年代。尝试加大年轻代的大小。
4.除了这些命令,还可以通过Jprofiler工具来分析
5.还可以找到占用CPU最多的线程,定位到具体的方法,优化这个方法的执行,看能否避免某些对象的创建。
对于已经发生了OOM的系统:
1.一般生产系统会设置,当发生OOM时,生成dump文件。
2.利用Jprofiler工具来分析dump文件
3.根据dump文件找到异常的实例对象,和异常的线程(占用CPU高),定位到具体的代码
排查内存泄漏
类加载器
作用:加载class文件
什么是类加载?类加载的过程?
虚拟机把描述类的数据加载到内存里面,并对数据进行校验、解析和初始化,最终变成可以被虚拟机直接使用的class对象;
类的整个生命周期包括:
解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言 的运行时绑定(也称为动态绑定)
类加载过程如下:
加载,加载分为三步: 1、通过类的全类名获取该类的二进制流; 2、将该二进制流的静态存储结构转为方法区的运行时数据结构; 3、在堆中为该类生成一个class对象; 验证:验证该class文件中的字节流信息符合虚拟机的要求,不会威胁到jvm的安全; 准备:为class对象的静态变量分配内存,初始化其初始值; 解析:该阶段主要完成符号引用转化成直接引用; 初始化:到了初始化阶段,才开始执行类中定义的java代码;初始化阶段是调用类构造器的过程;
常见的类加载器
类加载器是指:通过一个类的全限定性类名获取该类的二进制字节流叫做类加载器
类加载器分为以下四种:
启动类加载器(BootStrapClassLoader):用来加载java核心类库,是用原生代码来实现的,无法被java程序直接引用; 扩展类加载器(Extension ClassLoader):用来加载java的扩展库,java的虚拟机实现会提供一个扩展库目录,该类加载器在扩展库目录里面查找并加载java类; 系统类加载器(AppClassLoader):它根据java的类路径来加载类,一般来说,java应用的类都是通过它来加载的; 自定义类加载器:由java语言实现,继承自ClassLoader;
双亲委派机制
1.APP-->EXC---BOOT(最终执行)
向上委派,向下加载
1.类加载器收到类加载请求 2.将这个请求向上委托给父类加载器去完成,一直向上委托,到达启动类加载器 3.启动加载器检查是否能够加载当前这个类,能加载就结束,使用当前的加载器;否则,通知子加载器进行加载 4.重复步骤3,如果均加载失败,就会抛出ClassNotFoundException异常。 Boot:Java调用不到,因为底层用C、C++写的,native本地方法
为什么需要?为了安全
避免类被重复加载;避免篡改核心类
通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
为了防止内存中出现多个相同的字节码;
因为如果没有双亲委派的话,用户就可以自己定义一个 java.lang.String类,那么就无法保证类的唯一性
(我们自定义的String类本应用系统类加载器,但它并不会自己先加载,而是把这个请求委托给父类的加载器去执行,到了扩展类加载器发现String类不归自己管,再委托给父类加载器(启动类加载器),这时发现是java.lang包,这事就归引导类加载器管,所以加载的是 JDK 自带的 String 类)
打破双亲委派机制
但是由于加载范围的限制,顶层的ClassLoader无法访问底层的ClassLoader所加载的类。所以此时需要破坏双亲委派模型(有一个类想要通过自定义的类加载器来加载这个类,而不是通过系统默认的类加载器,就不走双亲委派的那一套)
可以自定义类加载器,继承ClassLoader类,重写loadClass方法和findClass方法。
打破的例子:
1.Tomcat,应用的类加载器优先自行加载应用目录下的 class,并不是先委派给父加载器,加载不了才委派给父加载器。
1.一个tomcat可以运行多个Web应用程序,对于各个webapp中的class和lib ,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况。 (Tomcat给每个Web应用创建一个类加载器实例WebAppClassLoader,该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找) 2.并不是Web应用程序下的所有依赖都需要隔离的,比如Redis就可以Web应用程序之间共享,如果版本相同,没必要每个Web应用程序都独自加载一份,把需要应用程序之间需要共享的类放到一个共享目录下。Tomcat就在WebAppClassLoader上加了个父类加载器SharedClassLoader,如果WebAppClassLoader自身没有加载到某个类,那就委托SharedClassLoader去加载。 3.与jvm一样的安全性问题。使用单独的 classloader(CatalinaClassLoader)) 去装载 tomcat 自身的类库,隔绝Web应用程序与Tomcat本身的类,以免其他恶意或无意的破坏; 4.如果Tomcat本身的依赖和Web应用还需要共享,那么还有类加载器(CommonClassLoader)来装载进而达到共享
2.JDBC是否打破了?
JDBC定义了接口,具体实现由各个厂商进行实现,比如Mysql。
类加载有个规则:如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。
用JDBC的时候,是使用DriverManager进而获取Connection,DriverManager在java.sql包下,显然是由BootStrap启动类加载器进行装载。使用DriverManager.getConnection()时,得到的一定是厂商实现的类。
但启动类不可能加载到各个厂商实现的类。
解决方法是获取Connection时,使用线程上下文加载器去加载Connection。(实际上线程上下文加载器还是APP系统类加载器)获取链接的时候,先找Ext和Boot,肯定加载不到,最终还是由APP来加载。
这种情况,有人认为没破坏双亲委派机制,只是改成由「线程上下文加载器」进行类加载,但还是遵守着:「依次往上找父类加载器进行加载,都找不到时才由自身加载」的原则。
有的人觉得破坏了双亲委派机制,因为本来明明应该是由BootStrap ClassLoader进行加载的,结果你来了一手「线程上下文加载器」,改掉了「类加载器」。
3.OSGi,实现模块化热部署,为每个模块都自定义了类加载器,需要更换模块时,模块与类加载器一起更换。其类加载的过程中,有平级的类加载器加载行为。打破的原因是为了实现模块热替换。
沙箱安全机制
组成沙箱的基本组件:
字节码校验器:确保Java类文件遵循Java语言规范。
GC算法
GC的算法有哪些?标记清除法,标记压缩,复制算法,引用计数法(少)
标记清除法
分为“标记”和“清除”两个阶段:首先标记出所有不需要回收的对象,标记完成后统一回收掉所有没有被标记的对象。
它是最基础的收集算法,后续的算法都是对其不足进行改进得到。
标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象; 清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
缺点:效率问题,两次扫描严重浪费时间;空间问题,会产生内存碎片
优点:不需要额外空间
标记-整理算法
标记过程与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。
防止内存碎片产生,多了一个移动成本
复制算法
将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收。
优点:没有内存碎片
缺点:浪费了内存空间,一半空间永远是空。
最佳使用场景:对象存活度较低的时候
分代收集算法
当前虚拟机的垃圾收集都采⽤分代收集算法,根据对象存活周期的不同将内存分为⼏块。⼀般将 java 堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
在新⽣代中,每次收集都会有⼤量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
⽽⽼年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。
总结:
内存效率:复制算法>标记清除法>标记压缩算法(时间复杂度)
内存整齐度:复制算法=标记压缩算法>标记清除算法
内存利用率:标记压缩算法=标记清除法>复制算法;
常见的垃圾回收器
还没有最好的垃圾收集器,根据具体应⽤场景选择适合⾃⼰的垃圾收集器。
Serial收集器:
串行收集器是最基本、历史最悠久的垃圾收集器,是单线程的。
“单线程” 不仅意味着它只会使⽤⼀条垃圾收集线程去完成 垃圾收集⼯作,更重要的是它在进⾏垃圾收集⼯作的时候必须暂停其他所有的⼯作线程,直到它收集结束。
优点是简单⽽⾼效(与其他收集器 的单线程相⽐)。Serial 收集器对于运⾏在 Client 模式下的虚拟机来说是个不错的选择。
新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。
ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使⽤多线程进⾏垃圾收集外,其余⾏为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全⼀样。
它是许多运⾏在 Server 模式下的虚拟机的⾸要选择。
新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。
Parallel Scanvenge收集器
关注点是吞吐量(⾼效率的利⽤ CPU)。CMS 等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验)。所谓吞吐量就是 CPU 中⽤于运⾏⽤户代码 的时间与 CPU 总消耗时间的⽐值。
并行扫描收集器提供了很多参数供⽤户找到最合适的停顿时间或最⼤吞吐量,如果对于收集器运作不太了解,⼿⼯优化存在困难的时候,使⽤ Parallel Scavenge 收集器配合⾃适应调节策略,把内存管理优化交给虚拟机去完成也是⼀个不错的选择。
新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。
CMS收集器
CMS(Concurrent Mark Sweep,并发标记扫描)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它 ⾮常符合在注重⽤户体验的应⽤上使⽤。
是 HotSpot 虚拟机第⼀款真正意义上的并发收集器, 它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作。
“标记-清除”算法实现
1.初始标记: 暂停所有的其他线程,并记录下直接与GC root相连的对象,速度很快; 2.并发标记: 同时开启GC和⽤户线程,根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞。 3.重新标记: 因为第2步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。这个阶段的停顿时间⼀般会⽐初始标记阶段的时间稍⻓,远远⽐并发标记阶段时间短 4.并发清除: 开启⽤户线程,同时 GC 线程开始对未标记的区域做清扫。
优点:并发收集、低停顿;
缺点:
并发回收导致CPU资源紧张;
⽆法处理浮动垃圾;
在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。
内存碎片问题, 它使⽤的回收算法-“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产⽣。
G1收集器
G1 (Garbage-First) 是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极⾼概率满⾜ GC 停顿时间要求的同时,还具备⾼吞吐量性能特征。采用面向局部收集的设计思路和基于Region的内存布局形式
特点:
并⾏与并发:使⽤多个CPU来缩短GC停顿时间。部分其他收集器原本需要停顿 Java 线程执 ⾏的 GC 动作,G1 收集器仍然可以通过并发的⽅式让 java 程序继续执⾏。 分代收集:虽然G1可以不需要其他收集器配合就能独⽴管理整个GC堆,但是还是保留了分代的概念。 空间整合:与CMS的“标记清除”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。 可预测的停顿:这是 G1 相对于 CMS 的另⼀个⼤优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建⽴可预测的停顿时间模型,能让使⽤者明确指定在⼀个⻓度为 M 毫秒的时间⽚段内。
步骤:
1.初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象。这个阶段需要停顿线程,但耗时很短。 2.并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。 3.最终标记:对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。 4.筛选回收:更新Region(区域)的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。
G1 收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最⼤的 Region。
这种使⽤ Region 划分内存空间以及有优先级的区域回收⽅式,保证了 G1 收集器在有限时间内有尽可能⾼的收集效率(把内存化整为零)。
ZGC 收集器
ZGC 也采⽤标记-复制算法,不过 ZGC 对该算法做了重⼤改进。在 ZGC 中出现停顿时间的情况会更少!