jdk1.8内存模型
JVM栈 ,本地方法栈,java堆,方法区,程序计数器
JVM内存模型按线程是否共享可分为线程共享区域与线程私有区域:
一、线程共享区域:
即所有线程都共享的区域。
1、方法区:
-
方法区为JVM规范中的一部分,不是实际的实现。主要用来存放已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据。
-
JVM规范没有要求在方法区中使用垃圾回收,因为回收效率太低。
-
常量池:存放编译器生成的各种字面量和符号引用,在类加载后放到运行时常量池中。
2、java堆
-
是JVM内存模型中最大的一块区域,是大部分类实例、new对象、数组分配内存的区域。
-
JVM规范没有限制对象实例只能在 java堆分配,所以会出现逃逸分析的技术。
-
逃逸分析技术:
**逃逸:**是指在某个方法之内创建的对象除了在方法体之内被引用之外,还在方法体之外被其它变量引用到;这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法被GC回收(被其他变量引用),导致其所占用内存无法得到及时回收,即称为逃逸。
**逃逸分析技术:**可以分析出某个对象是否永远只在某个方法、线程的范围内,并没有“逃逸”出这个范围,逃逸分析的一个结果就是对于某些未逃逸对象可以直接在栈上分配提高对象分配回收效率,对象占用的空间会随栈帧的出栈而销毁。
-
java堆内存分配:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ckoi6Ckd-1595924312090)(…/img/堆的内存分配.png)]
- 新生代(占据堆的1/3):
-
进入条件:
优先选择在新生代的Eden区被分配。
-
细分:
- Eden
- Survivor
- 默认 Eden 与 survivor 的比例是 8:1:1。
- 老年代:
- 进入条件:
- 大对象,-XX:PretenureSizeThreshold 大于这个参数的对象直接在老年代分配,来避免新生代GC以及分配担保机制和Eden与Survivor之间的复制。
- 长期存活的对象,经过第一次Minor GC仍然存在,能被Survivor容纳,就会被移动到Survivor中,此时年龄为1,当年龄大于预设值就进入老年代。
- 如果Survivor中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象进入老年代。
- 如果Survivor空间无法容纳新生代中Minor GC之后还存活的对象,则无法容纳的对象进入老年代。
-
TLAB:
JVM在内存新生代Eden Space中开辟了一小块线程私有的区域TLAB(Thread-local allocation buffer)。在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有,所以没有锁开销。
也就是说,Java中每个线程都会有自己的缓冲区称作TLAB,在对象分配的时候不用锁住整个堆,而只需要在自己的缓冲区分配即可。
二、线程私有(隔离)区域
即每个线程都会有一块私有的数据区。
1、程序计数器(PC寄存器):
- JVM中一块内存较小的区域。
- 程序计数器是一个记录着当前线程所执行的字节码的行号指示器。
2、虚拟机栈
-
与线程同时创建,生命周期与线程相同。
-
虚拟机栈描述的是 java 方法执行的内存模型,每个方法在执行的同时都会创建一个 栈帧(Stack Frame),
用以存储局部变量表、操作数栈、动态链接、方法出口等信息。
-
每个方法从调用到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
局部变量表:用以存储编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和ReturnAddress类型。
3、本地方法栈
- 功能与虚拟机栈相同,只是虚拟机栈为java方法服务,而本地方法栈为native方法服务。
三、jdk1.8与JVM规范中的区别
1、直接内存
非Java标准,是JVM以外的本地内存,在Java4出现的NIO中,为了防止Java堆和Native堆之间往复的数据复制带来的性能损耗,以提高IO性能,此后NIO可以使用Native方法直接在Native堆分配内存。JDK中有一种基于通道(Channel)和缓冲区(Buffer)的内存分配方式,将由C语言实现的native函数库分配在直接内存中,用存储在JVM堆中的DirectByteBuffer来引用。
2、元数据区(方法区的实现)
Java7以及之前是使用的永久代来实现方法区,大小是在JVM启动时固定的,设得太小程序在运行时容易出现永久代OOM,设得太大又挤压了Java堆的内存,难以选择。所以,Java8中用元空间替代了永久代来实现方法区,**元空间并不在虚拟机中,而是使用本地内存,并且大小可以是自动增长的,这样减少了方法区OOM的可能性。**元空间存储 JIT 即时编译后的native代码,可能还存在短指针数据区CCS。
3、堆区
Java7时,运行时常量池从方法区移到了堆区,为Java8移除永久带的做好准备。
JVM垃圾回收机制
在java中,程序员是不需要显示的去释放一个对象的内存的,而是由虚拟机自行执行。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫秒那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。
为什么会有GC
可以自动检测对象是否超过作用域从而达到自动回收内存的目的
一、判断对象是否存活(需要回收)
1、引用计数法
给每一个对象设置一个引用计数器,每当有一 个地方引用这个对象时,就将计数器加一,引用失效时,计数器就 减一。当一个对象的引用计数器为零时,说明此对象没有被引用, 也就是“死对象”,将会被垃圾回收
2、可达性分析算法:
通过一系列的GC Roots的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有与任何引用链相连时,则说明此对象是不可用的,即不可达的。
-
GC Root:
可作为 GC Root 的对象有:
虚拟机栈(主要是栈帧中的局部变量表)中引用的对象。
方法区中静态变量引用的对象。
方法区中常量引用的对象。
本地方法栈 JNI(即 Native方法)引用的对象。
-
判断对象真正死亡:
要宣告一个对象真正死亡至少需要经过两次标记:
-
对象经过可达性分析后没有与任何 GC Roots 链连接。(被标记筛选)
筛选条件:
是否有必要执行finalize() 方法?
-是:对象有覆盖 finalize() 方法且 finalize() 方法没有被执行过。
-否:将被GC清除。
-
在finalize()方法中没有逃脱回收(没有将自身与GC Root链上的对象建立连接),这是对第一次标记的清理。(标记清除)
-
二、如何回收(分代回收:按存活周期将内存分为新生代,老年代,永久代)
新生代因为每次GC都有大批对象死去,只需要付出少量存活对象的复制成本且无碎片所以使用“复制算法”。
老年代因为存活率高、没有分配担保空间,所以使用“标记-清除”或者“标记-整理”算法
新生代的特点:每次GC都有大量的对象需要被回收
老年代的特点:每次垃圾收集只有少量对象需要被回收
复制算法:
将可用内存划分为相等的两块,分配内存时只使用其中的一块,每次GC后,将还存活的对象移到没有被使用的那一块上,然后清除已被使用的内存块。此算法不会产生内存碎片,但每次都要预留一半的内存,损失也太大了。
Java8中的使用:将可用内存按容量划分为Eden、from survivor、to survivor,对象分配的时候使用Eden和一个survivor,Minor GC后将存活的对象复制到另一个survivor,然后将原来已使用的内存一次清理掉。这样没有内存碎片。
标记-清除:
首先标记出所有需要回收的对象,标记完成后统一回收被标记的对象。标记-清除算法有个弊端,即每次GC后都会会产生大量的内存碎片,以后可能会导致无法为大对象分配内存,从而频繁触发GC,大幅降低系统性能。
标记-整理:
首先标记出所有需要回收的对象,让所有存活的对象向一端移动,再统一收回另一端的空间,该算法没有产生内存碎片,但需要担负移动对象时的开销。
三、GC类型及其触发条件
-
Minor GC(新生代GC):
当Eden区空间不足以继续分配新对象时,发起Minor GC,该 GC 只回收 新生代 的内存。
-
Major GC:
老年代空间不足,发起 Major GC,只回收老年代的内存。但一般发起Major GC时也要发起Minor GC,所以JVM会直接发起一次 Full GC,故Major GC比较少见。
-
Full GC:
- 调用System.gc时,系统建议执行Full GC,但是不必然执行.
- 老年代空间不足(通过Minor GC后进入老年代的大小大于老年代的可用内存)。
- 方法区空间不足。
四、垃圾收集器
1、串行收集器(Serial)
- 串行收集器Serial是最古老的收集器,只使用一个线程去回收,可能会产生较长的停顿(Stop the World)。
- 新生代使用Serial收集器(
复制算法
)、老年代使用Serial Old(标记-整理算法
)。 - 参数:
-XX:+UseSerialGC
,默认开启-XX:+UseSerialOldGC
2、并行收集器(Parallel)
-
ParNew(新生代)
ParNew垃圾收集器是Serial收集器的多线程版本。
-
Parallel Scavenge(新生代)
- 并行收集器Parallel关注可控的吞吐量,能精确地控制吞吐量与最大停顿时间是该收集器最大的特点,也是1.8的Server模式的默认收集器,使用多线程收集。
- 新生代
复制
算法、老年代标记-整理
算法。 - 参数:
-XX:+UseParallelGC
,默认开启-XX:+UseParallelOldGC
- 注:Parallel Scavenge无法与CMS配合工作。
-
Parallel Old(老年代)
- 多线程、标记整理、关注吞吐量。
3、并发收集器(CMS、G1)
-
串行、并行、并发的区别:
- 串行:只能单一垃圾收集线程执行任务,用户线程处于等待状态。
- 并行:多垃圾收集线程同时执行任务,但用户线程仍处于等待状态。
- 并发:多垃圾收集线程共同执行任务,但不一定是并行的,可能是交替执行,用户线程可继续运行。
-
CMS(老年代):
-
并发收集器CMS是以最短停顿时间为目标的收集器,只负责老年代的垃圾收集。
-
CMS针对老年代,收集步骤如下:
- 初始标记(CMS initial mark):停顿,串行,时间短。
- 并发标记(CMS concurrent mark):不停顿,并发,时间较长。
- 重新标记(CMS remark):停顿,串行,时间稍长
- 并发清除(CMS concurrent sweep):不停顿,并发,时间长
-
CMS使用
标记-清除
算法,所以会产生内存碎片。 -
参数:
-XX:+UseConcMarkSweepGC
,默认开启-XX:+UseParNewGC
-
优点:并发收集、低停顿(Stop The World)。
-
缺点:
- 对CPU资源很敏感。
- 无法处理浮动垃圾。
- 基于
标记-清除
算法,会产生内存碎片。
**浮动垃圾:**即CMS在并发清理阶段,用户线程还在继续运行着,只要用户线程还在运行就可能产生新的垃圾,因为这些垃圾是在CMS标记之后产生的,所以CMS无法在单次清除中回收掉这些垃圾,只能留给下一次GC来处理,这部分垃圾就称为浮动垃圾。
-
-
G1(新生代、老年代):
- G1关注能在回收大部分内存的前提下精确控制停顿时间且垃圾回收效率高。
- G1将堆划分为多个大小固定的独立区域,根据每次允许的收集时间优先回收垃圾最多的区域,使用
标记-整理
算法,是jdk1.9的Server模式的默认收集器。 -XX:+UseG1GC
- 特点:并发并行、分代收集、空间整合、可预测的停顿。
- 收集步骤:
- 初始标记(Initial Marking):停顿,串行,耗时很短。
- 并发标记(Concurrent Marking):不停顿,可并发,耗时较长。
- 最终标记(Final Marking):停顿,可并行,耗时短。
- 筛选回收(Live Data Counting and Evacuation):默认停顿(时间短),以提高收集效率,但可并发(时间稍长、不需要停顿)。
4、如何选择垃圾收集器
- 单CPU或小内存,单机程序
- -XX:+UseSerialGC
- 多CPU,需要最大吞吐量,如后台计算型应用
- -XX:+UseParallelGC
- -XX:+UseParallelOldGC
- 多CPU,追求低停顿时间,需快速响应如互联网应用
- -XX:+UseConMarkSweepGC
- -XX:+ParNewGC
5、Stop The World
Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互。
STW总会发生,不管是新生代还是老年代,比如CMS在初始标记和重复标记阶段会停顿,G1在初始标记阶段也会停顿,所以并不是选择了一款停顿时间低的垃圾收集器就可以避免STW的,我们只能尽量去减少STW的时间。
那么为什么一定要STW?
当gc线程在处理垃圾的时候,其它java线程要停止才能彻底清除干净,否则会影响gc线程的处理效率增加gc线程负担,特别是在垃圾标记的时候。
因为在定位堆中的对象时JVM会记录下对所有对象的引用,如果在定位对象过程中,有新的对象被分配或者刚记录下的对象突然变得无法访问,就会导致一些问题,比如部分对象无法被回收,更严重的是如果GC期间分配的一个GC Root对象引用了准备被回收的对象,那么该对象就会被错误地回收。
6、内存泄漏
内存泄漏是指一个不再被程序使用的对象或变量还在内存中占有存储空间。
-
两种情况
- 堆中申请的空间没有释放
- .对象已经不再使用,但是仍在内存中保留
-
泄漏原因
- 静态集合类
- 各种连接
- 变量不合理的作用域
- 监听器
- 单例模式
-
解决
- 避免在循环中创建对象。
- 尽早释放无用对象的引用。(最基本的建议)
- 尽量少用静态变量,因为静态变量存放在永久代(方法区),永久代基本不 参与垃圾回收。
- 使用字符串处理,避免使用 String,应大量使用 StringBuffer,每一个 String 对象都得独立占用内存一块区域。
-
排查
- 使用Jsconsole
7、OOM(内存溢出)
程序申请内存时,没有足够的空间供其使用
原因
-
分配的少
-
应用用的太多
-
java.lang.StackOverflowError
- Java虚拟机栈溢出,一般是由于程序存在死循环或者深度递归调用造成的,栈设置太小也会出现此种溢出,可以通过-Xss来设置栈的大小
-
java.lang.OutOfMemoryError: Java heap space
- Java堆内存溢出,一般由于内存泄漏或者堆的大小设置不当引起,可以通过-Xms,-Xmx来修改
-
java.lang.OutOfMemoryError:GC overhead limit exceeded
- 由于JVM花费太长时间执行GC且只能回收很少的堆内存时抛出,如果Java进程花费98%以上时间执行GC,并且每次只要不到2%的堆被恢复,则JVM抛出错误。换句话说应用程序几乎耗尽了所以可用内存,垃圾收集器花太长时间清理他,多次失败。
-
java.lang.OutOfMemoryError:Direct buffer memory(直接内存溢出)
- 如果不断分配本地内存,堆内存很少使用,那么JVM就不需要执行GC,DirectByteBuffer对象就不会被回收,这时候内存充足,但是本地内存可能已经使用光,再次尝试分类内存会出现OOM,程序直接崩溃。
-
java.lang.OutOfMemoryError:unable to create new native thread
- 高并发请求服务器时产生,准确讲native thread异常与对应的平台有关
- 产生原因
- 应用创建太多线程,一个应用程序创建多个线程,超过系统承载极限
- 服务器不允许应用程序创建多个线程,Linux默认允许的那个进程可以创建的线程数为1024个,应用程序超过就会报异常
- 解决方法
- 想办法降低应用程序创建线程的数量,分析应用是否需要创建这么多线程,如果不是改代码,将线程数降到最低
- 对于有的应用,确实需要创建很多线程,远超Linux系统默认线程的限制,可以通过修改Linux服务器配置,扩大Linux默认限制
-
java.lang.OutOfMemoryError:Metaspace
8、Linux常用命令
- 整机:top
- CPU:vmstat
- 内存:free -m
- 硬盘:df -h
- 磁盘IO:iostat -xdk 2 3
- 网络IO:ifstat
9、CPU占用过高
-
先用top命令找出CPU占比最高的进程PID
-
top-h 查找出CPU占用较高的线程,转换为十六进制
-
jstack打印当前进程的线程栈
-
查找到第二步的两个线程,分析代码
10、OOM排查
- 使用top命令查询服务器系统状态
- ps-aux|grep java(jps)找出当前java进程的pid
- jstat-gcutil pid interval查看当前GC状态
- jmap从高到底查看占据内存最多的对象
- jmap-dump生成dump文件
- 使用性能分析工具对dump文件进行分析如jhat等
11、减少GC出现次数
- 对象不用时最好显式置为 Null
- 尽量少用 System.gc()
- 尽量少用静态变量
- 尽量使用 StringBuffer,而不用 String 来累加字符串。
- 分散对象创建或删除的时间
- 尽量少用 finalize 函数
- 如果需要使用经常用到的图片,可以使用软引用类型
- 能用基本类型如 int,long,就不用 Integer,Long 对象
- 增大-Xmx 的值
12、常用参数
如果分配的堆过于小,会频繁的发生GC,没有足够时间去运行应用程序
如果分配的堆过于大,停顿时间也会变长,会让程序的整体性能变慢
整个堆范围,不同代的大小划分由新生代所占空间控制的
- -Xms -Xms等价于-XX:InitialHeapSize,初始大小内存,默认物理内存1/64
- -Xmx -Xmx等价于-XX:MaxHeapSize,最大分配内存,默认物理内存1/4
- -Xss -Xss 等价于-XX:ThreadStackSize,设置单个线程栈的大小,一般默认512k~1024k
- -Xmn 设置新生区年轻代的大小
- -XX:MetaspaceSize 设置元空间大小
- 元空间的本质和永久代(堆中)类似,都是对JVM规范中方法区的实现。元空间和永久代最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间大小仅受本地内存限制
- -XX:+PrintGCDetails 输出详细GC收集日志信息
- -XX:SurvivorRatio 设置新生代中eden和S0/S1空间比例
- 默认Eden:S0: S1=8:1:1
- -XX:NewRatio 配置年轻代与老年代在堆结构的占比
- 默认新生代1,老年代2,年轻代占整个堆的1/3
- -XX:MaxTenuringThreshold 设置垃圾最大年龄 如果设置问0,年轻代不经过Survivor区,直接进入老年代
13、jvm常用工具
- jps:显示本地的java进程,并显示进程号
- jinfo运行环境参数
- jstat监视虚拟机各种运行状态信息
- jstack观察jvm中当前所有线程的运行情况和线程的当前状态
- jamp观察jvm物理内存的占用情况
JVM类加机制
JVM类加载机制:
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行验证、准备、解析和初始化,使其最终形成可以被虚拟机直接使用的 Java 类型的过程。
类的生命周期:
类从被加载到虚拟机内存开始,到卸载出内存为止。类的整个生命周期可分为:**加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(initialization)、使用(Using)、和卸载(Unloading)**7个阶段。
类的加载方式:
- 命令行启动应用时,jvm初始化加载
- Class.forName()
- ClassLoader.loadClass()动态加载
- 1和2使用的类加载器是相同的,都是当前类加载器(即:this.getClass.getClassLoader)。
- 3由用户指定类加载器。如果需要在当前类路径以外寻找类,则只能采用第3种方式。即第3种方式加载的类与当前类分属不同的命名空间。
- 1是静态加载,2、3是动态加载
一、类加载过程
- 加载(查找并加载类的二进制数据,生成Class对象)。
- 验证(确保被加载类的正确性)。
- 准备(为类变量【static修饰的变量】分配内存并设置初始值,若被final修饰,则设置为final指定的值)。
- 解析(类的符号引用转换为直接引用)。
- 初始化(类变量进行初始化)。
二、初始化时机
1、时机
- 遇到 new、getstatic、putstatic、invokestatic 指令时,如new一个对象及静态字段/方法被使用的场景。
- 使用java.lang.reflect包对类进行反射调用时。
- 如果被初始化的类的父类没有被初始化,先初始化父类。
- 程序执行入口,即执行main()方法时,main() 方法所在的类需要先初始化。
- 使用动态语言支持时(JDK1.7及以后),如java.lang.invoke.MethodHandle实例最后的解析结果为:REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,若这些方法句柄所对应的类还未初始化,需先触发其初始化。
总结:以上5种情况称为对一个类的主动引用,除此之外都是被动引用。主动引用时若类还未初始化过,则需先触发其初始化。
2、细节
-
引用一个类的静态字段,只有直接定义这个字段的类才会触发初始化。
-
通过定义一个类的数组来引用该类,不会触发该类的初始化。
-
引用一个类的常量不会触发该类的初始化。
常量在编译阶段会直接存入调用(者)类的常量池中,本质上没有引用到定义该常量的类,故不会触发其初始化。
三、类加载器
自定义类加载器:
-
创建一个类继承ClassLoader抽象类
-
重写findClass()方法
-
在findClass()方法中调用defineClass()
-
启动类加载器(BootStrap ClassLoader):
用C++语言实现,是虚拟机自身的一部分,它负责加载 <JAVA_HOME>/lib路径下的核心类库(如rt.jar),无法被Java程序直接引用。
-
扩展类加载器(Extension ClassLoader):
用Java语言实现,它负责加载<JAVA_HOME>/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可直接使用该类加载器。
-
应用程序/系统类加载器(Application ClassLoader):
用Java语言实现,它负责加载用户类路径ClassPath上指定的类库,开发者可直接使用该类加载器。
四、双亲委派模型
-
定义:
每个类加载器在收到类加载请求时,都不会自己先加载,而是将该请求委派给父类加载器去完成,若父类加载器可以完成该类的加载请求任务,就成功返回,若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
-
优点:
采用双亲委派模式可使Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父加载器已经加载了该类时,就没有必要让子ClassLoader再加载一次。其次还可以防止子类加载器加载的类恶意覆盖Java核心API。
-
三次大型破坏双亲委派模式的事件:
-
JDK1.2时,为类加载器添加了findClass()方法。因为在双亲委派模式出来之前,用户继承ClassLoader就是为了重写loadClass()方法,但双亲委派模式需要这个方法,所以它不能被用户随意重写。
-
Java团队添加了一个线程上下文加载器,为了解决基础类要调回用户的代码的问题。如JNDI/JDBC需要调用ClassPath下用户的代码来进行资源管理,如果该加载器没有被设置过,那么就默认是应用程序类加载器【applicationClassLoad】。
线程上下文加载器的作用:可让父类加载器请求子类加载器去完成类加载的动作,这显然是违背了双亲委派的准则。
-
为了实现代码热替换。OSGi为了实现自己的类加载逻辑,用平级查找的逻辑替换掉了双亲委派模式向下传递的逻辑。但其实可以不破坏双亲委派逻辑而是自定义类加载器来达到代码热替换。比如这篇文章
-
-
打破双亲委派机制
- 自己写一个类加载器
- 重写loadclass方法
- 重写findclass方法
-
① 静态对象(变量)优先于非静态对象(变量)初始化,其中,静态对象(变量)只初始化一次,而非静态对象(变量)可能会初始化多次;
② 父类优先于子类进行初始化;
③ 按照成员变量定义顺序进行初始化,即使变量定义散布于方法定义中,它们依然在任何方法(包括构造方法)被调用之前先初始化
五、深拷贝和浅拷贝
浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址,
深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存,