有垃圾回收机制为啥会出现内存溢出

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了有垃圾回收机制为啥会出现内存溢出相关的知识,希望对你有一定的参考价值。

参考技术A 你没有开自动回收啊! 参考技术B 如果分配出去的内存得不到释放,及时回收,就会引起系统运行速度下降,甚至导致程序瘫痪,这就是内存泄露
GC机制
java内存分配和回收 都是jre后台进行, 简称GC机制,
JRE在回收时做了什么
jre 会提供一个后台线程 进行检测和控制, 使用垃圾回收算法进行(1)发现无用信息对象;(2)回收被无用对象占用的内存空间,使该空间可被程序再次使用。
回收的时机一般分为:CPU空闲,内存不足,内存使用极限
垃圾回收机制的缺点,优点,特点,小记
缺点,
无法精确控制垃圾回收的时机和顺序,虚拟机需要跟踪所有对象,确定有用和无用的对象,花费处理器时间,
优点:
不需要花太多时间 解决 存储器问题,缩短开发时间;安全性完整性,GC是一套完整机制
特点,
只能回收无用对象的内存空间,对于物理资源,如数据库链接, 磁盘IO流,网络链接等,等物理类型资源无法释放,需要手动释放处理。
有关函数
一.System.gc()方法
命令行参数透视垃圾收集器的运行
使用System.gc()可以不管JVM使用的是哪一种垃圾回收的算法,都可以请求Java的垃圾回收。
在命令行中有一个参数-verbosegc可以查看Java使用的堆内存的情况,它的格式如下:
 
  java -verbosegc classfile
  可以看个例子:

  
class TestGC

public static void main(String[] args)

   new TestGC();
   System.gc();
   System.runFinalization();
  

1
2
3
4
5
6
7
8
9
10
二. finalize()方法
在JVM垃圾回收器收集一个对象之前,一般要求程序调用适当的方法释放资源,但在没有明确释放资源的情况下,Java提供了缺省机制来终止该对象心释放资源,这个方法就是finalize()。它的原型为:
  protected void finalize() throws Throwable
  
  在finalize()方法返回之后,对象消失,垃圾收集开始执行。原型中的throws Throwable表示它可以抛出任何类型的异常。
  
  之所以要使用finalize(),是存在着垃圾回收器不能处理的特殊情况。假定你的对象(并非使用new方法)获得了一块“特殊”的内存区域,由于垃圾回收器只知道那些显示地经由new分配的内存空间,所以它不知道该如何释放这块“特殊”的内存区域,那么这个时候java允许在类中定义一个由finalize()方法。

特殊的区域例如:
1)由于在分配内存的时候可能采用了类似 C语言的做法,而非JAVA的通常new做法。这种情况主要发生在native method中,比如native method调用了C/C++方法malloc()函数系列来分配存储空间,但是除非调用free()函数,否则这些内存空间将不会得到释放,那么这个时候就可能造成内存泄漏。但是由于free()方法是在C/C++中的函数,所以finalize()中可以用本地方法来调用它。以释放这些“特殊”的内存空间。

2)又或者打开的文件资源,这些资源不属于垃圾回收器的回收范围。
1
2
3
4
2)又或者打开的文件资源,这些资源不属于垃圾回收器的回收范围。
换言之,finalize()的主要用途是释放一些其他做法开辟的内存空间,以及做一些清理工作。因为在JAVA中并没有提够像“析构”函数或者类似概念的函数,要做一些类似清理工作的时候,必须自己动手创建一个执行清理工作的普通方法,也就是override Object这个类中的finalize()方法。例如,假设某一个对象在创建过程中会将自己绘制到屏幕上,如果不是明确地从屏幕上将其擦出,它可能永远都不会被清理。如果在finalize()加入某一种擦除功能,当GC工作时,finalize()得到了调用,图像就会被擦除。要是GC没有发生,那么这个图像就会被一直保存下来。
一旦垃圾回收器准备好释放对象占用的存储空间,首先会去调用finalize()方法进行一些必要的清理工作。只有到下一次再进行垃圾回收动作的时候,才会真正释放这个对象所占用的内存空间。
  在普通的清除工作中,为清除一个对象,那个对象的用户必须在希望进行清除的地点调用一个清除方法。这与C++”析构函数”的概念稍有抵触。在C++中,所有对象都会破坏(清除)。或者换句话说,所有对象都”应该”破坏。若将C++对象创建成一个本地对象,比如在堆栈中创建(在Java中是不可能的,Java都在堆中),那么清除或破坏工作就会在”结束花括号”所代表的、创建这个对象的作用域的末尾进行。若对象是用new创建的(类似于Java),那么当程序员调用C++的 delete命令时(Java没有这个命令),就会调用相应的析构函数。若程序员忘记了,那么永远不会调用析构函数,我们最终得到的将是一个内存”漏洞”,另外还包括对象的其他部分永远不会得到清除。
  相反,Java不允许我们创建本地(局部)对象–无论如何都要使用new。但在Java中,没有”delete”命令来释放对象,因为垃圾回收器会帮助我们自动释放存储空间。所以如果站在比较简化的立场,我们可以说正是由于存在垃圾回收机制,所以Java没有析构函数。然而,随着以后学习的深入,就会知道垃圾收集器的存在并不能完全消除对析构函数的需要,或者说不能消除对析构函数代表的那种机制的需要(原因见下一段。另外finalize()函数是在垃圾回收器准备释放对象占用的存储空间的时候被调用的,绝对不能直接调用finalize(),所以应尽量避免用它)。若希望执行除释放存储空间之外的其他某种形式的清除工作,仍然必须调用Java中的一个方法。它等价于C++的析构函数,只是没后者方便。
在C++中所有的对象运用delete()一定会被销毁,而JAVA里的对象并非总会被垃圾回收器回收。In another word, 1 对象可能不被垃圾回收,2 垃圾回收并不等于“析构”,3 垃圾回收只与内存有关。也就是说,并不是如果一个对象不再被使用,是不是要在finalize()中释放这个对象中含有的其它对象呢?不是的。因为无论对象是如何创建的,垃圾回收器都会负责释放那些对象占有的内存。
  在这个例子中,一个新的对象被创建,由于它没有使用,所以该对象迅速地变为不可达,程序编译后,执行命令: java -verbosegc TestGC 后结果为:
  [Full GC 168K->97K(1984K), 0.0253873 secs]
  机器的环境为,Windows 2000 + JDK1.3.1,箭头前后的数据168K和97K分别表示垃圾收集GC前后所有存活对象使用的内存容量,说明有168K-97K=71K的对象容量被回收,括号内的数据1984K为堆内存的总容量,收集所需要的时间是0.0253873秒(这个时间在每次执行的时候会有所不同)。
需要注意的是,调用System.gc()也仅仅是一个请求(建议)。JVM接受这个消息后,并不是立即做垃圾回收,而只是对几个垃圾回收算法做了加权,使垃圾回收操作容易发生,或提早发生,或回收较多而已。
update
@11/18
一. jvm 内存 结构图

这里写图片描述

存储器
VM内存结构由堆、栈、本地方法栈、方法区等部分组成,另外JVM分别对新生代和旧生代采用不同的垃圾回收机制。
二.如何确定某个对象是“垃圾”?
我们先了解一个最基本的问题:如果确定某个对象是“垃圾”?既然垃圾收集器的任务是回收垃圾对象所占的空间供新的对象使用,那么垃圾收集器如何确定某个对象是“垃圾”?—即通过什么方法判断一个对象可以被回收了。
  在java中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一 个对象是否可以被回收。不失一般性,如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象 了。这种方式成为引用计数法。
  这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,因此在Java中并没有采用这种方式(Python采用的是引用计数法)。看下面这段代码:

public class Main
public static void main(String[] args)
MyObject object1 = new MyObject();
MyObject object2 = new MyObject();

object1.object = object2;
object2.object = object1;

object1 = null;
object2 = null;



class MyObject
public Object object = null;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。
  为了解决这个问题,在Java中采取了 可达性分析法。该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要 成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。
  至于可达性分析法具体是如何操作的我暂时也没有看得很明白,如果有哪位朋友比较清楚的话请不吝指教。
  下面来看个例子:

Object aobj = new Object ( ) ;
Object bobj = new Object ( ) ;
Object cobj = new Object ( ) ;
aobj = bobj;
aobj = cobj;
cobj = null;
aobj = null
1
2
3
4
5
6
7
第几行有可能会使得某个对象成为可回收对象?第7行的代码会导致有对象会成为可回收对象。至于为什么留给读者自己思考。
  再看一个例子:

String str = new String("hello");
SoftReference<String> sr = new SoftReference<String>(new String("java"));
WeakReference<String> wr = new WeakReference<String>(new String("world"));
1
2
3
这三句哪句会使得String对象成为可回收对象?

答:第2句和第3句,第2句在内存不足的情况下会将String对象判定为可回收对象,第3句无论什么情况下String对象都会被判定为可回收对象。
  
  最后总结一下平常遇到的比较常见的将对象判定为可回收对象的情况:
  1)显示地将某个引用赋值为null或者将已经指向某个对象的引用指向新的对象,比如下面的代码:

Object obj = new Object();
obj = null;
Object obj1 = new Object();
Object obj2 = new Object();
obj1 = obj2;
1
2
3
4
5
6
 2)局部引用所指向的对象,比如下面这段代码:

 
void fun()

.....
for(int i=0;i<10;i++)
Object obj = new Object();
System.out.println(obj.getClass());


1
2
3
4
5
6
7
8
9
 循环每执行完一次,生成的Object对象都会成为可回收的对象。
  3)只有弱引用与其关联的对象,比如:
WeakReference wr = new WeakReference(new String(“world”));
1

三.触发主GC(Garbage Collector)的条件
  JVM进行次GC的频率很高,但因为这种GC占用时间极短,所以对系统产生的影响不大。更值得关注的是主GC的触发条件,因为它对系统影响很明显。总的来说,有两个条件会触发主GC:
  1)当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。
  2)Java堆内存不足时,GC会被调用。当应用线程在运行,并在运行过程中创建新对象,若这时内存空间不足,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若GC一次之后仍不能满足内存分配的要求,JVM会再进行两次GC作进一步的尝试,若仍无法满足要求,则 JVM将报“out of memory”的错误,Java应用将停止。
  由于是否进行主GC由JVM根据系统环境决定,而系统环境在不断的变化当中,所以主GC的运行具有不确定性,无法预计它何时必然出现,但可以确定的是对一个长期运行的应用来说,其主GC是反复进行的。
四.减少GC开销的措施
  根据上述GC的机制,程序的运行会直接影响系统环境的变化,从而影响GC的触发。若不针对GC的特点进行设计和编码,就会出现内存驻留等一系列负面影响。为了避免这些影响,基本的原则就是尽可能地减少垃圾和减少GC过程中的开销。具体措施包括以下几个方面:
  (1)不要显式调用System.gc()
  此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。
  (2)尽量减少临时对象的使用
  临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。
  (3)对象不用时最好显式置为Null
  一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。
  (4)尽量使用StringBuffer,而不用String来累加字符串
  由于String是固定长的字符串对象,累加String对象时,并非在一个String对象中扩增,而是重新创建新的String对象,如Str5=Str1+Str2+Str3+Str4,这条语句执行过程中会产生多个垃圾对象,因为对次作“+”操作时都必须创建新的String对象,但这些过渡对象对系统来说是没有实际意义的,只会增加更多的垃圾。避免这种情况可以改用StringBuffer来累加字符串,因StringBuffer是可变长的,它在原有基础上进行扩增,不会产生中间对象。
  (5)能用基本类型如Int,Long,就不用Integer,Long对象
  基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。
  (6)尽量少用静态对象变量
  静态变量属于全局变量,不会被GC回收,它们会一直占用内存。
  (7)分散对象创建或删除的时间
  集中在短时间内大量创建新对象,特别是大对象,会导致突然需要大量内存,JVM在面临这种情况时,只能进行主GC,以回收内存或整合内存碎片,从而增加主GC的频率。集中删除对象,道理也是一样的。它使得突然出现了大量的垃圾对象,空闲空间必然减少,从而大大增加了下一次创建新对象时强制主GC的机会。
  
五. 关于垃圾回收的几点补充
  经过上述的说明,可以发现垃圾回收有以下的几个特点:3
  (1)垃圾收集发生的不可预知性:由于实现了不同的垃圾回收算法和采用了不同的收集机制,所以它有可能是定时发生,有可能是当出现系统空闲CPU资源时发生,也有可能是和原始的垃圾收集一样,等到内存消耗出现极限时发生,这与垃圾收集器的选择和具体的设置都有关系。
  
  (2)垃圾收集的精确性:主要包括2 个方面:(a)垃圾收集器能够精确标记活着的对象;(b)垃圾收集器能够精确地定位对象之间的引用关系。前者是完全地回收所有废弃对象的前提,否则就可能造成内存泄漏。而后者则是实现归并和复制等算法的必要条件。所有不可达对象都能够可靠地得到回收,所有对象都能够重新分配,允许对象的复制和对象内存的缩并,这样就有效地防止内存的支离破碎。
  
  (3)现在有许多种不同的垃圾收集器,每种有其算法且其表现各异,既有当垃圾收集开始时就停止应用程序的运行,又有当垃圾收集开始时也允许应用程序的线程运行,还有在同一时间垃圾收集多线程运行。
  
  (4)垃圾收集的实现和具体的JVM 以及JVM的内存模型有非常紧密的关系。不同的JVM 可能采用不同的垃圾收集,而JVM 的内存模型决定着该JVM可以采用哪些类型垃圾收集。现在,HotSpot 系列JVM中的内存系统都采用先进的面向对象的框架设计,这使得该系列JVM都可以采用最先进的垃圾收集。
  
  (5)随着技术的发展,现代垃圾收集技术提供许多可选的垃圾收集器,而且在配置每种收集器的时候又可以设置不同的参数,这就使得根据不同的应用环境获得最优的应用性能成为可能。
  
针对以上特点,我们在使用的时候要注意:
  (1)不要试图去假定垃圾收集发生的时间,这一切都是未知的。比如,方法中的一个临时对象在方法调用完毕后就变成了无用对象,这个时候它的内存就可以被释放。
  
  (2)Java中提供了一些和垃圾收集打交道的类,而且提供了一种强行执行垃圾收集的方法–调用System.gc(),但这同样是个不确定的方法。Java 中并不保证每次调用该方法就一定能够启动垃圾收集,它只不过会向JVM发出这样一个申请,到底是否真正执行垃圾收集,一切都是个未知数。
  
  (3)挑选适合自己的垃圾收集器。一般来说,如果系统没有特殊和苛刻的性能要求,可以采用JVM的缺省选项。否则可以考虑使用有针对性的垃圾收集器,比如增量收集器就比较适合实时性要求较高的系统之中。系统具有较高的配置,有比较多的闲置资源,可以考虑使用并行标记/清除收集器。
  
  (4)关键的也是难把握的问题是内存泄漏。良好的编程习惯和严谨的编程态度永远是最重要的,不要让自己的一个小错误导致内存出现大漏洞。
  
  (5)尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域(scope)后,自动设置为null,暗示垃圾收集器来收集该对象,还必须注意该引用的对象是否被监听,如果有,则要去掉监听器,然后再赋空值。
  
六.垃圾回收机制的意义
在C++中,对象所占的内存在程序结束运行之前一直被占用,在明确释放之前不能分配给其它对象;而在Java中,当没有对象引用指向原先分配给某个对象的内存时,该内存便成为垃圾。JVM的一个系统级线程会自动释放该内存块。垃圾回收意味着程序不再需要的对象是”无用信息”,这些信息将被丢弃。当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用。事实上,除了释放没用的对象,垃圾回收也可以清除内存记录碎片。由于创建对象和垃圾回收器释放丢弃对象所占的内存空间,内存会出现碎片。碎片是分配给对象的内存块之间的空闲内存洞。碎片整理将所占用的堆内存移到堆的一端,JVM将整理出的内存分配给新的对象。
  垃圾回收能自动释放内存空间,减轻编程的负担。这使Java 虚拟机具有一些优点。首先,它能使编程效率提高。在没有垃圾回收机制的时候,可能要花许多时间来解决一个难懂的存储器问题。在用Java语言编程的时候,靠垃圾回收机制可大大缩短时间。其次是它保护程序的完整性, 垃圾回收是Java语言安全性策略的一个重要部份。
  垃圾回收的一个潜在的缺点是它的开销影响程序性能。Java虚拟机必须追踪运行程序中有用的对象,而且最终释放没用的对象。这一个过程需要花费处理器的时间。其次垃圾回收算法的不完备性,早先采用的某些垃圾回收算法就不能保证100%收集到所有的废弃内存。当然随着垃圾回收算法的不断改进以及软硬件运行效率的不断提升,这些问题都可以迎刃而解。

详解JVM内存管理与垃圾回收机制 (上)

Java应用程序是运行在JVM上的,得益于JVM的内存管理和垃圾收集机制,开发人员的效率得到了显著提升,也不容易出现内存溢出和泄漏问题。但正是因为开发人员把内存的控制权交给了JVM,一旦出现内存方面的问题,如果不了解JVM的工作原理,将很难排查错误。本文将从理论角度介绍虚拟机的内存管理和垃圾回收机制,算是入门级的文章,希望对大家的日常开发有所助益。

一、内存管理

也许大家都有过这样的经历,在启动时通过-Xmx或者-XX:MaxPermSize这样的参数来显式的设置应用的堆(Heap)和永久代(Permgen)的内存大小,但为什么不直接设置JVM所占内存的大小,而要分别去设置不同的区域?JVM所管理的内存被分成多少区域?每个区域有什么作用?如何来管理这些区域?

1.1 运行时数据区

JVM在执行Java程序时会把其所管理的内存划分成多个不同的数据区域,每个区域的创建时间、销毁时间以及用途都各不相同。比如有的内存区域是所有线程共享的,而有的内存区域是线程隔离的。线程隔离的区域就会随着线程的启动和结束而创建和销毁。JVM所管理的内存将会包含以下几个运行时数据区域,如下图的上半部分所示。
技术分享图片

Method Area (方法区)

方法区是所有线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等数据。在Java虚拟机规范中,方法区属于堆的一个逻辑部分,但很多情况下,都把方法区与堆区分开来说。大家平时开发中通过反射获取到的类名、方法名、字段名称、访问修饰符等信息都是从这块区域获取的。

对于HotSpot虚拟机,方法区对应为永久代(Permanent Generation),但本质上,两者并不等价,仅仅是因为HotSpot虚拟机的设计团队是用永久代来实现方法区而已,对于其他的虚拟机(JRockit、J9)来说,是不存在永久代这一概念的。

但现在看来,使用永久代来实现方法区并不是一个好注意,由于方法区会存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等,在某些场景下非常容易出现永久代内存溢出。如Spring、Hibernate等框架在对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。在JSP页面较多的情况下,也会出现同样的问题。可以通过如下代码来测试:

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M(JDK6.0)
 * VM Args: -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M(JDK8.0)
 */
public class CGlibProxy {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(ProxyObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] os, MethodProxy proxy) throws Throwable {
                    System.out.println("I am proxy");
                    return proxy.invokeSuper(o,os);
                }
            });
            ProxyObject proxy = (ProxyObject) enhancer.create();
            proxy.greet();
        }
    }
    static class ProxyObject {
        public String greet() {
            return "Thanks for you";
        }
    }
}

在JDK1.8中运行一小会儿出现内存溢出错误:

Exception in thread "main" I am proxy
java.lang.OutOfMemoryError: Metaspace
    at org.mockito.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:238)
    at org.mockito.cglib.proxy.Enhancer.createHelper(Enhancer.java:378)
    at org.mockito.cglib.proxy.Enhancer.create(Enhancer.java:286)
    at com.lwork.mdo.CGlibProxy.main(CGlibProxy.java:23)

在JDK1.8下并没有出现我们期望的永久代内存溢出错误,而是Metaspace内存溢出错误。这是因为Java团队从JDK1.7开始就逐渐移除了永久代,到JDK1.8时,永久代已经被Metaspace取代,因此在JDK1.8并没有出现我们期望的永久代内存溢出错误。在JDK1.8中,JVM参数-XX:PermSize-XX:MaxPermSize已经失效,取而代之的是-XX:MetaspaceSizeXX:MaxMetaspaceSize。注意:Metaspace已经不再使用堆空间,转而使用Native Memory。关于Native Memory,下文会详细说明。

还有一点需要说明的是,在JDK1.6中,方法区虽然被称为永久代,但并不意味着这些对象真的能够永久存在了,JVM的内存回收机制,仍然会对这一块区域进行扫描,即使回收这部分内存的条件相当苛刻。

Runtime Constant Pool (运行时常量池)

回过头来看下图1的下半部分,方法区主要包含:

  1. 运行时常量池(Runtime Constant Pool)
  2. 类信息(Class & Field & Method data)
  3. 编译器编译后的代码(Code)等等
    后面两项都比较好理解,但运行时常量池有何作用,其意义何在?抛开运行时3个字,首先了解下何为常量池。

Java源文件经编译后得到存储字节码的Class文件,Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中。也就是说,哪个字节代表什么含义,长度多少,先后顺序如何都是被严格限定的,是不允许改变的。比如:开头的4个字节存放在魔数,用于确定这个文件是否能够被JVM接受,接下来的4个字节用于存放版本号,再接着存放的就是常量池,常量池的长度是不固定的,所以,在常量池的入口存放着常量池容量的计数值。

常量池主要用于存放两大类常量:字面量和符号引用量,字面量相当于Java语言层面常量的概念,比如:字符串常量、声明为final的常量等等。符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。理解不了?举个例子,有如下代码:

public class M {
    private int m;
    private String mstring = "chen";
    public void f() {
    }
}

使用javap工具输出M.class文件字节码的部分内容如下:

? javap -verbose M
  ......
Constant pool:
   #1 = Methodref          #5.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // chen
   #3 = Fieldref           #4.#22         // com/lwork/mdo/M.mstring:Ljava/lang/String;
   #4 = Class              #23            // com/lwork/mdo/M
   #5 = Class              #24            // java/lang/Object
   #6 = Utf8               m
   #7 = Utf8               I
   #8 = Utf8               mstring
   #9 = Utf8               Ljava/lang/String;
  #10 = Utf8               <init>
  #11 = Utf8               ()V
  #12 = Utf8               Code
  #13 = Utf8               LineNumberTable
  #14 = Utf8               LocalVariableTable
  #15 = Utf8               this
  #16 = Utf8               Lcom/lwork/mdo/M;
// 方法名称
  #17 = Utf8               f
  #18 = Utf8               SourceFile
// 类名称
  #19 = Utf8               M.java
  #20 = NameAndType        #10:#11        // "<init>":()V
  #21 = Utf8               chen
  #22 = NameAndType        #8:#9          // mstring:Ljava/lang/String;
// 类的完整路径,注意class文件中是用"/"来代替"."
  #23 = Utf8               com/lwork/mdo/M
  #24 = Utf8               java/lang/Object
......

这里只保留了常量池的部分,从中可以看到M.class文件的常量池总共24项,其中包含类的完整名称、字段名称和描述符、方法名称和描述符等等。当然其中还包含IV&lt;init&gt;LineNumberTableLocalVariableTable等代码中没有出现过的常量,其实这些常量是用来描述如下信息:方法的返回值是什么?有多少个参数?每个参数的类型是什么…… 这个示例非常直观的向大家展示了常量池中存储的内容。

接下来就比较好理解运行时常量池了。我们都知道:Class文件中存储的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。运行时常量池就可以理解为常量池被加载到内存之后的版本,但并非只有Class文件中常量池的内容才能进入方法区的运行时常量池,运行期间也可能产生新的常量,它们也可以放入运行时常量池中。

Heap Space (Java堆)

Java堆是JVM所管理的最大一块内存,所有线程共享这块内存区域,几乎所有的对象实例都在这里分配内存,因此,它也是垃圾收集器管理的主要区域。从内存回收的角度来看,由于现在的收集器基本都采用分代收集算法,所以Java堆又可以细分成:新生代和老年代,新生代里面有分为:Eden空间、From Survivor空间、To Survivor空间,如图1所示。有一点需要注意:Java堆空间只是在逻辑上是连续的,在物理上并不一定是连续的内存空间。

默认情况下,新生代中Eden空间与Survivor空间的比例是8:1,注意不要被示意图误导,可以使用参数-XX:SurvivorRatio对其进行配置。大多数情况下,新生对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,则触发一次Minor GC,将对象Copy到Survivor区,如果Survivor区没有足够的空间来容纳,则会通过分配担保机制提前转移到老年代去。

何为分配担保机制?在发送Minor GC前,JVM会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果是,那么可以确保Minor GC是安全的,如果不是,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于,直接进行Full GC,如果大于,将尝试着进行一次Minor GC,Minor GC失败才会触发Full GC。注:不同版本的JDK,流程略有不同

Survivor区作为Eden区和老年代的缓冲区域,常规情况下,在Survivor区的对象经过若干次垃圾回收仍然存活的话,才会被转移到老年代。JVM通过这种方式,将大部分命短的对象放在一起,将少数命长的对象放在一起,分别采取不同的回收策略。关于JVM内存分配更直观的介绍,请阅读参考资料3。

VM Stack (虚拟机栈) & Native Method Stack (本地方法栈)

虚拟机栈与本地方法栈都属于线程私有,它们的生命周期与线程相同。虚拟机栈用于描述Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

其中局部变量表用于存储方法参数和方法内部定义的局部变量,它只在当前函数调用中有效,当函数调用结束,随着函数栈帧的销毁,局部变量表也随之消失;操作数栈是一个后入先出栈,用于存放方法运行过程中的各种中间变量和字节码指令 (在学习栈的时候,有一个经典的例子就是用栈来实现4则运算,其实方法执行过程中操作数栈的变化过程,与4则预算中栈中数字与符号的变化类似);动态连接其实是指一个过程,即在程序运行过程中将符号引用解析为直接引用的过程。

如何理解动态连接?我们知道Class文件的常量池中存有大量的符号引用,在加载过程中会被原样的拷贝到内存里先放着,到真正使用的时候就会被解析为直接引用 (直接引用包含:直接指向目标的指针、相对偏移量、能间接定位到目标的句柄等)。有些符号引用会在类的加载阶段或者第一次使用的时候转化为直接引用,这种转化称为静态解析,而有的将在运行期间转化为直接引用,这部分称为动态连接。

全部静态解析不是更好,为何会存在动态连接?Java多态的实现会导致一个引用变量到底指向哪个类的实例对象,或者说该引用变量发出的方法调用到底是调用哪个类中实现方法都需要在运行期间才能确定。因此有些符号引用在类加载阶段是不知道它对应的直接引用的

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程,下面通过一个非常简单的图例来描述这一过程,有如下的代码片段:

public void sayHello(String name) {
    System.out.println("hello " + name);
    greet(name);
    bye();
}

其调用过程中虚拟机栈的大致示意图如下图所示:
技术分享图片
调用sayHello方法时,在栈中分配有一块内存用来保存该方法的局部变量等信息,①当函数执行到greet()方法时,栈中同样有一块内存用来保存greet方法的相关信息,当然第二个内存块位于第一个内存块上面,②接着从greet方法返回,③现在栈顶的内存块就是sayHello方法的,这表示你已经返回到sayHello方法,④接着继续调用bye方法,在栈顶添加了bye方法的内存块,⑤接着再从bye方法返回到sayHello方法中,由于没有别的事了,现在就从sayHello方法返回。

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

Program Counter Register (程序计数器)

程序计数器(Program Counter Register),很多地方也被称为PC寄存器,但寄存器是CPU的一个部件,用于存储CPU内部重要的数据资源,比如在汇编语言中,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。

类似的,JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined。

Java虚拟机可以支持多条线程同时执行,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,JVM中的程序计数器是每个线程私有的。

1.2 堆外内存

堆外内存又被称为直接内存(Direct Memory),它并不是虚拟机运行时数据区的一部分,Java虚拟机规范中也没有定义这部分内存区域,使用时由Java程序直接向系统申请,访问直接内存的速度要优于Java堆,因此,读写频繁的场景下使用直接内存,性能会有提升,比如Java NIO库,就是使用Native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectBytedBuffer对象作为这块内存的引用进行操作。

由于直接内存在Java堆外,其大小不会直接受限于Xmx指定的堆大小,但它肯定会受到本机总内存大小以及处理器寻址空间的限制,因此我们在配置JVM参数时,特别是有大量网络通讯场景下,要特别注意,防止各个内存区域的总内存大于物理内存限制 (包括物理的和OS的限制)。

1.3 小结

花了很大篇幅来介绍Java虚拟机的内存结构,其中在讲解Java堆时,还简单的介绍了JVM的内存分配机制;在介绍虚拟机栈的同时,也对方法调用过程中栈的数据变化作了形象的说明。当然这样的篇幅肯定不足以完全理清整个内存结构以及其内存分配机制,你尽可以把它当做简单的入门,带你更好的学习。接下来会以此为背景介绍一些常用的JVM参数。

二、常用JVM参数

2.1 关于JVM参数必须知道的小知识

  1. JVM参数分为标准参数和非标准参数,所有以-X-XX开头的参数都是非标准参数,标准参数可以通过java -help命令查看,比如:-server就是一个标准参数。
  2. 非标准参数中,以-XX开头的都是不稳定的且不推荐在生成环境中使用。但现在的情况已经有所改变,很多-XX开头的参数也已经非常稳定了,但不管什么参数在使用前都应该了解它可能产生的影响。
  3. 布尔型参数,-XX:+表示激活选项,-XX:-表示关闭此选项。
  4. 部分参数可以使用jinfo工具动态设置,比如:jinfo -flag +PrintGCDetails 12278,能够动态设置的参数很少,所以用处有限,至于哪些参数可以动态设置,可以参考jinfo工具的使用方法。

2.2 GC日志

GC日志是一个非常重要的工具,它准确的记录了每一次GC的执行时间和结果,通过分析GC日志可以帮助我们优化内存设置,也可以帮助改进应用的对象分配方式。如何阅读GC日志不在本文的范畴内,大家可以参考网上相关文章。

下面几个关于GC日志的参数应该加入到应用启动参数列表中:

  • -XX:+PrintGCDetails 开启详细GC日志模式
  • -XX:+PrintGCTimeStamps在每行GC日志头部加上GC发生的时间,这个时间是指相对于JVM的启动时间,单位是秒
  • -XX:+PrintGCDateStamps在GC日志的每一行加上绝对日期和时间,推荐同时使用这两个参数,这样在关联不同来源的GC日志时很有帮助
  • -XX:+PrintHeapAtGC输出GC回收前和回收后的堆信息,使用这个参数可以更好的观察GC对堆空间的影响
  • -Xloggc设置GC日志目录

设置这几个参数后,发生GC时输出的日志就类似于下面的格式 (不同的垃圾收集器格式可能略有差异):

2018-01-07T19:45:08.627+0800: 0.794: [GC (Allocation Failure) [PSYoungGen: 153600K->4564K(179200K)] 153600K->4580K(384000K), 0.0051736 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
......

简单的说明:

  • 2018-01-07T19:45:08.627+0800 - GC开始时间
  • 0.794 - GC开始时间相对于JVM启动时间
  • GC - 用来区分是Minor GC 还是 Full GC,这里是Minor GC
  • Allocation Failure - GC原因,这里是因为年轻代中没有任何足够空间,也就是分配失败
  • PSYoungGen - 垃圾收集算法,这里是Parallel Scavenge
  • 153600K->4564K(179200K) - 本次垃圾回收前后年轻代内存使用情况,括号内表示年轻代总大小
  • 153600K->4580K(384000K) - 在本次垃圾回收前后整个堆内存的使用情况,括号内表示总的可用堆内存
  • 0.0051736 secs - GC持续时间
  • [Times: user=0.01 sys=0.00, real=0.01 secs] - 多个维度衡量GC持续时间

2.3 内存优化

我们的程序可能会经常出现性能问题,但如何分析和定位?知道一些常用的JVM内存管理参数,对我们开发人员有莫大的帮助。

堆空间设置

使用-Xms-Xmx来指定JVM堆空间的初始值和最大值,比如:

java -Xms128m -Xmx2g app

虽然JVM可以在运行时动态的调整堆内存大小,但很多时候我们都直接将-Xms-Xmx设置相等的值,这样可以减少程序运行时进行垃圾回收的次数。

新生代设置

参数-Xmn用于设置新生代大小,设置一个较大的新生代会减少老年代的大小,这个参数堆GC行为影响很大。一般情况下不需要使用这个参数,在分析GC日志后,发现确实是因为新生代设置过小导致频繁的Full GC,可以配置这个参数,一般情况下,新生代设置为堆空间的1/3 - 1/4左右。

还可以通过-XX:SurviorRatio设置新生代中eden区和Survivor from/to区空间的比例关系,也可使用-XX:NewRatio设置新生代和老年代的比例。

配置这3个参数的基本策略是:尽可能将对象预留在新生代,减少老年代GC的次数,所以需要更谨慎的对其进行修改,不要太随意。

生成快照文件

我们可能没有办法给最大堆内存设置一个合适的值,因为我们时常面临内存溢出的状况,当然我们可以在内存溢出情况出现后,再监控程序,dump出内存快照来定位,但这种方法的前提条件是内存溢出问题要再次发生。更好方法是通过设置-XX:+HeapDumpOnOutOfMemoryError让JVM在发生内存溢出时自动的生成堆内存快照。有了这个参数,当我们在面对内存溢出异常的时候会节约大量的时间,-XX:HeapDumpPath则可以设置快照的生成路径。堆内存快照文件可能很庞大,要注意存储的磁盘空间。

方法区设置

方法区中存放中JVM加载的类信息,如果JVM加载的类过多,就需要合理设置永久大的大小,在JDK1.6和JDK1.7中,可以使用 -XX:PermSize-XX:MaxPermSize来达到这个目的,前者用于设置永久代的初始大小,后者用于设置永久代的最大值。前面我们知道,方法区并不在堆内存中,所以要注意所有JVM参数设置的内存总大小。

在JDK1.8中已经使用元空间代替永久代,同样的目的,需要使用-XX:MetaspaceSize-XX:MaxMetaspaceSize来代替。

直接内存

参数-XX:MaxDirectMemorySize用于配置直接内存大小 ,如果不设置,默认值为最大堆空间,即-Xmx,当直接内存使用量达到设置的值时,就会触发垃圾回收,如果垃圾回收不能有效释放足够空间,仍然会引起OOM。如果堆外内存发生OOM,请检查此参数是否配置过小。

2.4 小结

这部分主要介绍一些常用的JVM参数,理解这些JVM参数的前提是需要理解JVM的内存结构以及各个内存区域的作用,希望通过这些参数的介绍,能够加深大家对JVM内存结构的理解,也希望在平时的工作中能够注意这些参数的运用。下篇文章将着重介绍常用的垃圾回收算法与垃圾收集器。

参考资料

  1. 周志明 著; 深入理解Java虚拟机(第2版); 机械工业出版社,2013
  2. Java8内存模型—永久代(PermGen)和元空间(Metaspace)
  3. java虚拟机:运行时常量池
  4. 最简单例子图解JVM内存分配和回收
  5. JVM的内存区域划分
  6. JVM实用参数(八)GC日志
  7. JVM实用参数(四)内存调优

以上是关于有垃圾回收机制为啥会出现内存溢出的主要内容,如果未能解决你的问题,请参考以下文章

GC垃圾回收机制

java垃圾回收

java垃圾回收

Java内存与垃圾回收篇(对象内存与垃圾回收机制)下篇

面试题~ 垃圾回收机制得优点以及原理

Python之垃圾回收机制与用户交互