本文篇幅较长,预计阅读时长半小时左右,建议先加收藏再看。

前方高能预警,各位看管需要要有一定的java基础,请备好瓜子、饮料、小板凳,摆个让自己舒服的姿势,慢慢细看^_^, 文中所有素材,均来自互联网。如有侵权,请联系我。
一、前言:

Java作为一种面向对象的,跨平台语言,其对象、内存等一直是比较难的知识点。而且很多概念的名称看起来又那么相似,很多人会傻傻分不清楚。比如本文我们要讨论的JVM内存结构、Java内存模型和Java对象模型,这就是三个截然不同的概念,但是很多人容易弄混。
二、JVM内存结构

我们都知道,Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。

其中有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域结构如下:

    方法区和堆是所有线程共享的内存区域;
    而虚拟机栈、本地方法栈和程序计数器是运行是线程私有的内存区域。

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,存储虚拟机加载的类信息、常量、静态变量,即时编译器编译后的代码等数据。方法区是一种规范,永久代是方法区的一种实现,这里有个常考的面试题:JDK 7 以前的版本字符串常量池是放在永久代中的,JDK 7 将字符串常量池移动到了堆中,JDK 8 直接删除了永久代,改用元空间替代永久代。对于不同的虚拟机实现来说,是有一定的自由度的。

Java堆(Heap),是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。是 GC 的主要管理区域,又可分为年轻代、老年代、永久代,JDK 8 及以后去掉了永久代。

程序计数器(Program Counter Register),程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。通过改变这个计数器的值来选取下一条需要执行的字节码指令。多个线程之间的程序计数器相互独立,互不影响,为了保证每个线程都恢复后都可以找到具体的执行位置。

JVM栈(JVM Stacks)也是线程私有的,它的生命周期与线程相同。Java 方法执行的内存模型,Java 栈中存放的是多个栈帧,每个栈帧对应一个被调用的方法,主要包括局部变量表、操作数栈、动态链接、方法返回地址(方法出口)。每一个方法的执行,JVM 都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

本地方法栈(Native Method Stacks),本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务,执行的是本地方法。

以上,JVM内存结构,由Java虚拟机规范定义。描述的是Java程序执行过程中,由JVM管理的不同数据区域。各个区域有其特定的功能。
三、Java内存模型

Java内存模型看上去和Java内存结构(JVM内存结构)差不多,很多人会误以为两者是一回事儿,这也就导致面试过程中经常答非所为。

在前面的关于JVM的内存结构的图中,我们可以看到,其中Java堆和方法区的区域是多个线程共享的数据区域。也就是说,多个线程可能可以操作保存在堆或者方法区中的同一个数据。这也就是我们常说的“Java的线程间通过共享内存进行通信”。那么java虚拟机从底层是怎么操作的呢。

Java内存模型是根据英文Java Memory Model(JMM)翻译过来的。其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。JSR-133: Java Memory Model and Thread Specification 中描述了,JMM是和多线程相关的,他描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。

那么,简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字。

在JMM中,我们把多个线程间通信的共享内存称之为主内存,而在并发编程中多个线程都维护了一个自己的本地内存(这是个抽象概念),其中保存的数据是主内存中的数据拷贝。而JMM主要是控制本地内存和主内存之间的数据交互的。

所以,我理解的就是JMM就是为了解决Java多线程对共享数据的读写一致性问题而产生的一种模型!

下图是一个反映了主内存与线程工作内存(即是本地内存)之间的经典的关系图:

.
三、jvm调优

首先需要注意的是在对JVM内存调优的时候不能只看操作系统级别Java进程所占用的内存,这个数值不能准确的反应堆内存的真实占用情况,因为GC过后这个值是不会变化的,因此内存调优的时候要更多地使用JDK提供的内存查看工具,比如JConsole和Java VisualVM。

对JVM内存的系统级的调优主要的目的是减少GC的频率和Full GC的次数,过多的GC和Full GC是会占用很多的系统资源(主要是CPU),影响系统的吞吐量。特别要关注Full GC,因为它会对整个堆进行整理,导致Full GC一般由于以下几种情况:

旧生代空间不足

调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在旧生代创建对象。

Pemanet Generation空间不足

增大Perm Gen空间,避免太多静态对象;

统计得到的GC后晋升到旧生代的平均大小大于旧生代剩余空间;

控制好新生代和旧生代的比例。

System.gc()被显示调用

垃圾回收不要手动触发,尽量依靠JVM自身的机制

调优手段主要是通过控制堆内存的各个部分的比例和GC策略来实现,下面来看看各部分比例不良设置会导致什么后果

新生代设置过小

一是新生代GC次数非常频繁,增大系统消耗;

二是导致大对象直接进入旧生代,占据了旧生代剩余空间,诱发Full GC。

新生代设置过大

一是新生代设置过大会导致旧生代过小(堆总量一定),从而诱发Full GC;

二是新生代GC耗时大幅度增加;

一般说来新生代占整个堆1/3比较合适。

Survivor设置过小

导致对象从eden直接到达旧生代,降低了在新生代的存活时间

Survivor设置过大

导致eden过小,增加了GC频率;

另外,通过-XX:MaxTenuringThreshold=n来控制新生代存活时间,尽量让对象在新生代被回收;

由内存管理和垃圾回收可知新生代和旧生代都有多种GC策略和组合搭配,选择这些策略对于我们这些开发人员是个难题,JVM提供两种较为简单的GC策略的设置方式。

吞吐量优先

JVM以吞吐量为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,来达到吞吐量指标。这个值可由-XX:GCTimeRatio=n来设置

暂停时间优先

JVM以暂停时间为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,尽量保证每次GC造成的应用停止时间都在指定的数值范围内完成。这个值可由-XX:MaxGCPauseRatio=n来设置。
四、调优命令和工具
调优工具

常用调优工具分为两类,jdk自带监控工具:jconsole和jvisualvm,第三方有:MAT(Memory Analyzer Tool)、GChisto。

jconsole,Java Monitoring and Management Console是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存,线程和类等的监控。

jvisualvm,jdk自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC变化等。

MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗。

GChisto,一款专业分析gc日志的工具。

JVM调优命令

堆设置

-Xms:初始堆大小

-Xmx:最大堆大小

-XX:NewSize=n:设置年轻代大小

-XX:NewRatio=n:新生代和老生代占比。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4

-XX:SurvivorRatio=n:伊甸园空间和幸存者空间的占比。注意幸存者区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5

-XX:MaxPermSize=n:设置持久代大小

收集器设置

-XX:+UseSerialGC:设置串行收集器

-XX:+UseParallelGC:设置并行收集器

-XX:+UseParalledlOldGC:设置并行年老代收集器

-XX:+UseConcMarkSweepGC:设置并发收集器

垃圾回收统计信息

-XX:+PrintGC

-XX:+PrintGCDetails

-XX:+PrintGCTimeStamps

-Xloggc:filename

并行收集器设置

-XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。

-XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间

-XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)

并发收集器设置

-XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。

-XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。

如图

这里推荐一篇讲的比较好的:jvm调优-工具篇
五、垃圾回收算法与垃圾回收器
垃圾收集算法:

标记-清除算法:将所有需要回收的对象先进行标记,标记结束后对标记的对象进行回收,效率低,会造成大量碎片。

复制算法:将内存分为两块大小相等的空间,每次只用其中一块,若一块内存用完了,就将这块内存中活着的对象复制到另一快内存中,将已使用的进行清除。不会产生碎片,但是会浪费一定的内存空间。堆的年轻代使用此算法,因为年轻代对象多为生存周期比较短的对象。年轻代将空间分为一个Eden和两个survivor,每次只使用Eden加一个survivor,回收时,将Eden和survivor中存活的对象复制到另一个survivor上,最后清理Eden和survivor。当Eden与survivor存活对象大于另一个survivor空间大小则需要老年代来担保。

标记-整理算法:标记阶段与标记-清除算法相同,标记完成后将所有存活对象向一端移动,然后清除掉端边界外对象。

分代收集算法:根据对象存活周期分为将内存分为新生代与老年代,新生代采取复制算法,老年代采用标记清除或标记整理算法。

垃圾回收器:

Serial收集器:单线程,垃圾回收时需要停下所有的线程工作。

ParNew收集器:Serial的多线程版本。

Parallel Scavenge收集器:年轻代,多线程并行收集。设计目标是实现一个可控的吞吐量(cpu运行代码时间/cpu消耗的总时间)。

Serial Old收集器:Serial老年代版本。

CMS:目标是获得最短回收停顿时间,基于标记清除算法,整个过程四个步骤:初始标记(标记GCRoot直接关联对象,速度很快)、并发标记(从GCRoot向下标记)、重新标记(并发标记过程中发生变化的对象)、并发清除(清除老年代垃圾)。初始标记和重新标记需要停顿所有用户线程。缺点:无法处理浮动垃圾、有空间碎片的产生、对CPU敏感。

G1收集器:唯一一个可同时用于老年代与新生代的收集器。采用标记整理算法,将堆分为不同大小星等的Region,G1追踪每个region的垃圾堆积的价值大小,然后有一个优先列表,优先回收价值最大的region,避免在整个堆中进行安全区域的垃圾收集,能建立可预测的停顿时间模型。整个过程四个步骤:初始标记、并发标记、最终标记(并发标记阶段发生变化的对象的变化记录写入线程remembered set log,同时与remembered set合并)、筛选回收(对每个region回收价值和成本拍寻,得到一个最好的回收方案并回收)。

六、JVM内存泄露线上排查

如果应用程序执行时间越来越长,或者如果操作系统的执行速度越来越慢,这说明可能存在内存泄露问题。换句话说,虚拟机持续分配内存,但是内存不再需要时却无法回收。最终,应用程序或系统运行耗尽内存,并且应用系统异常终止。

先列举几种常见的报错信息:
1、堆溢出

java.lang.OutOfMemoryError: Java heap space

原因:

这种场景最为常见,它表明无法在Java堆中分配对象。这个错误并不一定意味着内存泄露。

可能仅仅是配置的问题,如应用程序指定的堆大小(或默认大小,如果不指定)不合适。
代码中可能存在大对象分配。
可能存在内存泄露,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。

措施:

检查是否存在大对象的分配,最有可能的是大数组,大的list(如果存在很大的对象,请尽可能预先分配好长度)
通过jmap命令,把堆内存dump下来,使用mat工具分析一下,检查是否存在内存泄露的问题
如果没有找到明显的内存泄露,使用 -Xmx 加大堆内存
还有一点容易被忽略,考虑是否过度使用finalizer,例如:应用程序创建高优先级线程,导致终结(finalization)队列的增长速度比finalizer线程为该队列提供服务的速度更快。

2、永久代/元空间溢出

java.lang.OutOfMemoryError: PermGen space

原因:

JDK1.8(不含)之前的JDK通过分配永久保存区域(Permanent Generation space)加载class,系统默认设置不能满足系统加载的要求,系统运行一段时间后,永久保存区域占满就出现内存溢出。JDK8后,元空间(MetaspaceSize)替换了永久代,元空间使用的是本地内存,还有其它细节变化:

字符串常量由永久代转移到堆中

和永久代相关的JVM参数已移除

措施:

检查是否永久代空间或者元空间设置的过小(jdk8中元空间的默认初始大小是20.75MB,默认的元空间的最大值是无限,受本地内存限制。)
检查代码中是否存在大量的反射操作
dump之后通过mat检查是否存在大量由于反射生成的代理类
重启JVM

3、GC Overhead limit exceeded

java.lang.OutOfMemoryError: GC Overhead limit exceeded

原因:

详细信息“GC Overhead limit exceeded”标识垃圾收集器一直在运行,Java程序进行非常慢。在一次垃圾回收之后,如果Java进程花费了超过大约98%的时间进行垃圾回收,而如果它回收小于2%的堆,并且在最后5次(编译时常数)连续垃圾收集中均如此,那么会抛出java.lang.OutOfMemoryError。抛出这个异常通常是由于Java堆无法容纳存活的数据量,没有多少可用空间用于新的分配。

措施:

检查项目中是否有大量的死循环或有使用大内存的代码,优化代码。
增加堆大小
如果没有,加大内存。

4、Metaspace

java.lang.OutOfMemoryError: Metaspace

原因:

错误所表达的信息是: 元数据区(Metaspace) 已被用满,这和Metaspace 的使用量与JVM加载到内存中的 class 数量/大小有关。可以说, java.lang.OutOfMemoryError: Metaspace 错误的主要原因, 是加载到内存中的 class 数量太多或者体积太大。

措施:

增加 Metaspace 的大小:-XX:MaxMetaspaceSize=512m

5、超大数组分配

java.lang.OutOfMemoryError:Requested array size exceeds VM limit

原因:

该错误表示应用程序(或者应用程序使用的API)试图分配大于堆大小的数组。例如,如果应用程序试图分配一个512MB的数组,但是最大堆大小是256MB,那么将抛出OutOfMemoryError,原因是“Requested array size exceeds VM limit”。

措施:

检查代码中是否存在创建大数组(例如sql不分页之类的)
调整堆的大小

6、request size bytes for reason

java.lang.OutOfMemoryError: request size bytes for reason. Out of swap space

原因:

当本地堆分配失败以及本地堆可能接近耗尽时,Java HotSpot VM代码会报告这个异常。该消息显示失败请求的大小(字节)以及内存请求原因。

措施:

调整堆的大小。

————————————————
版权声明:本文为CSDN博主「李人」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_26465035/article/details/103454023

发表评论

电子邮件地址不会被公开。 必填项已用*标注