目录
一、Java内存区域与内存溢出
1.内存分区
1)程序计数器
2)Java虚拟机栈
3)本地方法栈
4)堆区
5)方法区
6)常量池
7)直接内存
2.对象创建
1)对象在内存上的分布
2)对象的访问定位方式
3)对象创建
3.内存溢出
1)堆内存溢出和内存泄露
2)虚拟机栈及本地方法栈溢出
3)方法区和运行时常量池溢出
4)直接内存溢出
二、垃圾收集器与内存分配策略
1.对象是否存活
1)引用计数法
2)可达性算法
3)引用
2.垃圾回收算法
1)标记-清除
2)标记-复制
3)标记-整理
4)分代回收
3.垃圾收集器
4.内存分配与回收策略
1) 对象优先在Eden区上分配
2) 大对象直接进入老年代
3) 长期存活对象进入老年代
4) 动态对象年龄判定
5) 空间分配担保
三、性能监测工具与性能调优案例
1.工具
2.性能调优
1 ) 高性能硬件上的程序部署策略
2 )集群间同步导致的内存溢出
3 ) 堆外内存导致的溢出错误
4) 外部命令导致系统缓慢
5) 服务器JVM进程崩溃
6 ) 不恰当数据结构导致内存占用过大
7) 由Windows虚拟内存导致的长时间停顿
四、参考文章
Java虚拟机(JVM)在运行时的内存区域主要分为5块:程序计数器、Java虚拟机栈、本地方法栈(前三者线程私有),堆区、方法区(后两者线程共享);除了上述的5块在较老版本中的JVM的方法区中包含了常量池,另外还有一部分“直接内存”(例如NIO使用时的区域)并不属于JVM定义的内存范围。
从操作系统的层面来说,内存区域主要分为:内核态内存、用户内存;JVM的进程就运行在用户内存那块内存区域上,NIO之所以比Java API中定义的IO快的原因是:普通的IO需要将数据从磁盘拷贝到内核态缓存区后再拷贝到用户内存进行操作,而NIO的内存操作方式是直接将数据从磁盘拷贝到内核态内存缓存区域后直接在内核态内存上进行操作,图解如下:
另外,在有大量NIO的应用中会出现JVM内存空闲,但是程序却报内存溢出的异常,这时候很大概率就是NIO使用的直接内存区域 已经满了(指的是无法向系统申请更多的内存空间,物理内存达到上限)
用于记录线程运行字节码的下一条指令地址,如果执行的是本地方法栈中的方法那么程序计数器的指针无效(Undefined)
描述的是Java方法执行的内存模型(方法的执行过程相当于入栈、出栈的过程):每个栈帧存储的是一个运行方法的信息,包含局部变量表(存储基本类型变量及引用类型的引用)、动态链接、操作数栈(用于保存方法运行时的数据)、返回信息、附加信息
保存着操作系统中的方法信息,利用本地方法接口对操作系统执行操作-Native方法服务
实例对象产生与保存的区域,也是垃圾回收时的主要操作区域(垃圾回收时的新生代与老年代区域;垃圾回收主要针对堆区与方法区,方法区也就是我们常说的永久代内存区域,简单来说垃圾回收主要分为新生代:Eden区+Survivor区(分为from区与to区),老年代:Old区,永久代:Perm区)
保存着JVM加载的类型信息,持有Class对象作为对方法区中的Java类操作的接口
各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来
方法区的一部分,Class文件中除了有类的版 本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于 存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常 量池中存放;运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
在1.6以前版本的JVM中常量池中的指针指向的是常量池对象(即将堆区的对象放入常量池,会在常量池中生成一份拷贝数据然后常量池的指针指向该拷贝区域),在1.7以后的版本中常量池指针直接指向堆区(如果对象放入常量池,不存在会直接将引用指向该对象的内存地址而不会生成拷贝数据)[2],图解如下:
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规 范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制
主要分为三个:对象头(包含mask word,对象类型信息-指向方法区类型),实例数据,对齐填充
对象头:第一类数据:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;第二类数据:类型指针,指向对象的类元数据,虚拟机通过这个确定该对象的类(具体方式见对象的访问定位,说明对象怎么查找类型)
实例数据:对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来
对齐填充:并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用,内存管理的地址必须是8字节的整数倍,当对象定以后不满足要求时,就需要进行对齐填充
主要分为句柄型和直接型
虚拟机遇到一条new指令会去常量池中检查该对象是否被加载、解析及初始化过,若是没有则会执行类加载过程(主要是加载、验证、准备、解析、初始化),然后在内存区域上为类分配空间
首先根据内存区域是否规整(用过的在一边,没用过的在另一边)分为两种分配方式:指针碰撞(规整,在内存区域移动指针一段距离-对象大小的距离)、空闲列表(从内存区域的空闲列表中选取一块大于对象所需内存的空闲内存区域为对象分配内存)
接着根据线程安全将多线程在内存上分配方式分为两种:CAS碰撞重试、本地线程分配缓冲(TLAB,就是每个线程在内存上隔离一块区域分配内存),在对象内存分配的时候会将内存空间都初始化为零值(不包含对象头)
紧接着虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
在上面步骤完成后,以虚拟机视角新对象已经创建完成,从Java程序视角还需执行init方法将字段按程序员意愿初始化才算创建完成新对象
//确保常量池中存放的是已解释的类
if(!constants->tag_at(index).is_unresolved_klass()){
//断言确保是klassOop和instanceKlassOop(这部分下一节介绍)
oop entry=(klassOop)*constants->obj_at_addr(index);
assert(entry->is_klass(),"Should be resolved klass");
klassOop k_entry=(klassOop)entry;
assert(k_entry->klass_part()->oop_is_instance(),"Should be instanceKlass");
instanceKlass * ik=(instanceKlass*)k_entry->klass_part();
//确保对象所属类型已经经过初始化阶段
if(ik->is_initialized()&&ik->can_be_fastpath_allocated())
{
//取对象长度
size_t obj_size=ik->size_helper();
oop result=NULL;
//记录是否需要将对象所有字段置零值
bool need_zero=!ZeroTLAB;
//是否在TLAB中分配对象
if(UseTLAB){
result=(oop)THREAD->tlab().allocate(obj_size);
}
if(result==NULL){
need_zero=true;
//直接在eden中分配对象
retry:
HeapWord * compare_to=*Universe:heap()->top_addr();
HeapWord * new_top=compare_to+obj_size;
/*cmpxchg是x86中的CAS指令,这里是一个C++方法,通过CAS方式分配空间,如果并发失败,
转到retry中重试,直至成功分配为止*/
if(new_top<=*Universe:heap()->end_addr()){
if(Atomic:cmpxchg_ptr(new_top,Universe:heap()->top_addr(),compare_to)!=compare_to){
goto retry;
}
result=(oop)compare_to;
}
}
if(result!=NULL){
//如果需要,则为对象初始化零值
if(need_zero){
HeapWord * to_zero=(HeapWord*)result+sizeof(oopDesc)/oopSize;
obj_size-=sizeof(oopDesc)/oopSize;
if(obj_size>0){
memset(to_zero,0,obj_size * HeapWordSize);
}
}
//根据是否启用偏向锁来设置对象头信息
if(UseBiasedLocking){
result->set_mark(ik->prototype_header());
}else{
result->set_mark(markOopDesc:prototype());
}
result->set_klass_gap(0);
result->set_klass(k_entry);
//将对象引用入栈,继续执行下一条指令
SET_STACK_OBJECT(result,0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3,1);
}
}
}
内存溢出主要是堆上对象产生过多造成溢出
内存泄露主要是堆上空闲对象持有大量空闲空间不释放,无法GC造成,例如HashMap
分为StackOverflowError与OutOfMemoryError
单线程不管局部变量产生多少、栈帧大小设置多少只会出现线程请求的栈深度大于虚拟机所允许的最大深度,抛出StackOverflowError异常
多线程才会造成OutOfMemoryError
主要原因如下:
操作系统分配给每个进程的内存是有限制的,譬如32位的Windows限制为2GB。虚拟机提供了参数来控制Java堆和方法区的这两部分内存的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽
主要是运行时动态加载对象造成,主要方式有:CGLib字节码增强和动态语言(反射)、大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等
明显的特征是在Heap Dump文件中不会看见明显 的异常,如果发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就 可以考虑检查一下是不是这方面的原因
对象被引用就会在对象头将计数加1,不能 解决循环引用问题,即A引用B,B引用A
由根对象(GC Roots)开始向下搜索节点树判断对象是否存活
GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)引用的对象
安全点(Safepoint):安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的
a.循环的末尾 (防止大循环的时候一直不进入 safepoint,而其他线程在等待它进入
safepoint)
b. 方法返回前
c. 调用方法的 call 之后
d. 抛出异常的位置
安全区域(Safe Region):在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint
强引用:使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它[3]
软引用:如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
弱引用:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存
虚引用:顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
首先标记出所有需要回收的对象,在标记完成后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
新生代采用标记-复制算法,老年代采用标记-复制/整理算法
GC分区主要分为[4]:新生代,老年代,永久代(堆=新生代+老年代,方法区=永久代)
新生代分为(Minor GC,采用标记复制算法,每次清空Eden与survivor的一块,存放新对象):Eden区和Survivor区(分为两块from与to,也叫0区与1区,使用标记复制算法在GC时两者存数据的地方就互换)
老年代(Full GC,采用标记清除/整理算法):Old Gen,主要存放应用程序中生命周期长的对象,GC耗时较久
永久代:Perm Gen(存放Class和Meta信息,一般不GC,hotspotJVM 有对其GC:即可以GC但基本不GC,方法区中常量的GC只要要求除常量池有这个常量引用其他地方没有引用就可以GC,对类的GC要求所有实例已回收、加载该类的classloder已回收、没有其Class的引用才可以回收)
GC后对象区域变动:Eden->survivor->old
Serial收集器(主要用于新生代):单线程串行停顿JVM标记对象,停顿虚拟机进行垃圾回收
ParNew收集器(主要用于新生代):Serial收集器的并行版本,多线程并行标记单线程回收
Parallel Scavenge收集器(主要用于新生代):ParNew收集器基础上更加关注吞吐量的垃圾收集器,多线程标记、多线程回收(吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%)
Serial Old收集器(主要用于老年代):Serial收集器老年代版本,单线程串行停顿JVM标记对象,停顿虚拟机进行垃圾回收
Parallel Old收集器(主要用于老年代):Parallel Scavenge收集器的老年代版本,多线程标记、多线程回收
CMS收集器(Concurrent Mark Sweep,主要用于老年代):以获取最短回收停顿时间为目标的收集器
从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:
初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)
缺点:无法处理浮动垃圾(程序运行期间产生的垃圾),内存碎片(标记-清除算法的原因)
G1收集器(Garbage-First,可用于新生代与老年代):相比CMS优势在于可预测的的停顿,内存碎片少(内存整理)
G1收集器的运作大致可划分为以下几个步骤:
初始标记(Initial Marking)、并发标记(Concurrent Marking)、最终标记(Final Marking)、筛选回收(Live Data Counting and Evacuation)
收集器的搭配方式:
在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代 最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行 一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置 不允许冒险,那这时也要改为进行一次Full GC。
命令行工具:
HSDIS:JIT生成代码反汇编
可视化工具:
JConsole:Java监视与管理控制台
VisualVM:多合一故障处理工具
在高性能硬件上部署程序,目前主要有两种方式:
a.通过64位JDK来使用大内存(可能造成GC时间长,建议采用集群方式)。
b.使用若干个32位虚拟机建立逻辑集群来利用硬件资源
在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出
大量使用NIO操作的系统,由于直接内存不足而JVM内存分配过大造成空闲,直接内存无法再向系统申请物理内存造成溢出
垃圾收集进行时,虚拟机虽然会对Direct Memory进行回收,但是Direct Memory却不能 像新生代、老年代那样,发现空间不足了就通
知收集器进行垃圾回收,它只能等待老年代满 了后Full GC,然后“顺便地”帮它清理掉内存的废弃对象。否则它只能一直等到抛出内存溢出
异常时,先catch掉,再在catch块里面“大喊”一声:“System.gc()!”。要是虚拟机还是不听 (譬如打开了
-XX:+DisableExplicitGC开关),那就只能眼睁睁地看着堆中还有许多空闲内 存,自己却不得不抛出内存溢出异常了。
Runtime.getRuntime().exec()方法来调用:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是CPU,内存负担也很重。
建议:去掉这种脚本执行方式
例如消息通知服务,使用异步的方式调用Web服务,但由于客户端与服务端两边服务速度的完全不对等,时间越长就累积了越多Web服务没有调用完成,导致在等待的线程和Socket连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。
解决方法:修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常
数据结构不恰当,致使空间效率低造成的内存占用过大
程序最小化的时候,资源管理器中显示的占用内存大幅度减小,但是Java虚拟机的虚拟内存则没有变化,因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生GC时就有可能因为恢复页面文件的操作而导致不正常的GC停顿
[1] 深入理解Java虚拟机(第二版)周志明
[2] 常量池之字符串常量池String.intern()
https://www.jianshu.com/p/4ee6aec39c89
[3] Java 7之基础 - 强引用、弱引用、软引用、虚引用
https://blog.csdn.net/mazhimazh/article/details/19752475
[4] jvm的新生代、老年代、永久代关系
https://blog.csdn.net/qq_19734597/article/details/80958817
[5] JVM调优总结(十)-调优方法
https://www.cnblogs.com/jay36/p/7680008.html