JVM
1. JVM运行时内存区域划分
根据《Java虚拟机规范》的规定,运行时数据区通常包括这几个部分:程序计数器Program Counter Register;Java栈VM Stack;本地方法栈Native Method Stack;方法区Method Area;堆Heap。
1.1 程序计数器
程序计数器也被称为PC寄存器。虽然JVM中的PC并不像汇编中的PC一样是物理概念上的CPU寄存器,但是JVM中的程序计数器的功能跟汇编语言中的PC的功能在逻辑上是等同的,也就是说是用来指示执行哪条指令的。
在JVM规范中规定,如果线程执行的是非native方法,则PC中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则PC中的值是undefined。
由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象OutOfMenmory的。
1.2 Java栈
Java栈也被称为虚拟机栈Java Vitual Machine Stack。Java栈是Java方法执行的内存模型。
Java栈中存放的是一个个的栈帧,每一个栈帧对应一个被调用的方法,在栈帧中包括局部变量表Local Variables,操作数栈Operand Stack,指向当前方法锁属的类的运行时常量池的引用Reference to runtime constant pool,方法返回地址Return Address和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。栈这部分空间对程序员来说是不透明的。
我们平常所说的栈一般都是指Java栈中的局部变量表。局部变量表用来存储方法中的局部变量(宝库在方法中声明的非静态变量一级函数形参)。对于基本数据类型的变量,则直接存储他的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译期就可以确定其打消了,因此在程序执行期间局部变量表的大小是不会改变的。
由于每一个线程正在执行的方法可能不会,因此每个线程都会有一个自己的Java栈,互不干扰。
1.3 本地方法栈
本地方法栈与Java栈的作用于原理非常相似。区别在于Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法Native Method服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构做强制规定,虚拟机可以自由实现它。在HotSpot虚拟机中就直接把本地方法栈和Java栈合二为一。
1.4 堆
Java中的堆是用来存储对象本身的以及数组(当然数组引用是放在Java栈中的)。堆是被所有线程共享的,在JVM中只有一个堆。
这部分空间是Java垃圾收集器管理的主要区域。
1.5 方法区
方法区域堆一样,是被线程共享的区域。
在方法区中,存储了每个类的信息(包括累的名称,方法信息,字段信息),静态变量,常量以及编译器编译后的代码等。
在方法区中有一个非常重要的部分就是运行时常量池。它是每个类或接口的常量池的运行时表示形式。在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非CLass文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。
在JVM规范中,没有强制要求方法区必须实现垃圾回收。很多人习惯将方法区称为永久代,因为HotSpot虚拟机以永久代来实现方法区,从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,不需要专门为这部分设计垃圾回收机制。
参考:
《JVM的内存区域划分》
《深入理解JVM之JVM内存区域与内存分配》
2. 内存溢出OOM和堆栈溢出SOE的示例及原因、如何排查与解决
内存溢出是由于没有被引用的对象(垃圾)过多而JVM没有或无法及时回收,造成了内存溢出。
造成内存溢出的原因可以分为两种:
- 内存泄漏:代码中的某个对象本硬背虚拟机回收,但因为拥有GCRoot引用而没有被回收。
- 内存溢出:虚拟机由于堆中拥有太多不可回收对象没有回收,导致的内存不足。
对于内存溢出,可以进行代码的检查:
- 是否应用中的类和引用变量过多使用了static修饰。
- 是否应用中使用了大量的递归或无线递归(递归中用到了大量的新建的对象)
- 是否应用中使用了大量循环或死循环(循环中用到了大量的新建对象)
- 检查应用中是否使用了像数据库查询所有记录的方法。如果数据量超过十万多条,就可能会造成内存溢出。相对的,可以采用高分页查询。
- 检查是否有数组,list,map中存放的是对象的引用而不是对象。因为这些引用会让对应的对象不能被GC回收而大量存储于内存中。
- 检查是否使用了非字面量字符串进行+的操作。因为String类的内容是不可变的,每次运行+就会产生新的对象。如果过多会造成内存溢出的情况。
而对于栈溢出的原因则可能有一下可能:
- 是否有递归调用
- 是否有大量循环或死循环
- 全局变量是否过多
- 数组,list,map数据是否过大
- 使用DDMS工具进行查找大概出现栈溢出的位置。
参考:
《java 内存溢出 栈溢出的原因与排查方法》
《Java内存溢出(OOM)异常完全指南》
3. 如何判断对象是否可以回收或存活
JVM要做垃圾回收时,首先要判断一个对象是否还有可能被使用。那么如何判断一个对象是否还有可能被用到?
如果我们的程序无法再引用到该对象,那么这个对象就肯定可以被回收,这个状态称为不可达。当对象不可达,该对象就可以作为回收对象被垃圾回器回收。
那么这个可达还是不可达如何判断呢?
答案就是GC roots,也就是根对象。如果从一个对象没有到达根对象的路径,或者说从根对象开始无法引用到该对象,该对象就是不可达的。
以下三类对象在JVM中作为GC roots,来判断一个对象是否可以被回收(通常来说我们只需要直到虚拟机栈和静态引用就够了)
- 虚拟机栈JVM stack中引用的对象(准确的说是虚拟机栈中的栈帧frames)
我们知道,每个方法执行的时候,JVM都会创建一个相应的栈帧(战争中包括操作数栈,局部变量表,运行时常量池的引用),栈帧中包含着在方法内部使用的所有对象的引用(当然还有其他的基本类型数据),当方法执行完后,该栈帧会从虚拟机栈中探出,这样一来,临时创建的对象的引用也就不存在了,或者说没有任何GC roots指向这些临时对象,这些对象在下一次GC是便会被回收掉 - 方法区中类静态属性引用的对象
静态属性是该类型class的属性,不单独属于任何实例,因此该属性自然会作为GC roots。只要这个class存在,该引用指向的对象也会一直存在。 - 本地方法栈Native Stack引用的对象
而如果要回收一个class(准确说是卸载),必须同时满足以下三个条件
- 堆中不存在该类的任何实例
- 加载该类的classloader已经被回收
- 该类的java.lang.Class对象没有在任何地方被引用。也就是说无法通过反射再访问该类的信息。
参考:
《深入理解java虚拟机》
4. 常见的GC回收算法及其含义
4.1 引用计数法
引用计数法是一种古老的垃圾手机方法。引用计数器实现很简单:对于一个对象A,有任何一个对象引用了A,nameA的计数器+1;引用是小事,A的计数器-1.当A的引用计数器是0时,A对象就不能被使用了。
引用计数法很简单,就是额外的为每一个对象设置一个计数器用来计算引用的数量。但是缺点也是很致命的:
- 高并发时引用计数器的每次加减操作都需要加锁,会影响系统性能。
- 无法处理循环引用问题。比如A引用了B,B引用了A,但除此之外再没有任何引用引用到A与B。那么此时实际上A与B都不可能再被外界访问到了,但却因为计数器不为0而无法被回收。因此,在Java的垃圾回收器中,并没有使用这种算法。
4.2 标记清除法
标记清楚算法将垃圾的回收分两阶段进行:标记阶段和清除阶段。
在标记阶段,从根节点开始标记可到达的对象,没有被标记的对象也就是GC roots不可达对象,被认为是没有被引用的对象。而在第二阶段也就是清除阶段,将清除所有没有被标记的对象。
这种算法最大的问题就是清除操作会产生大量的空间碎片,回收后的空间是不连续的。
4.3 复制算法
复制算法将分配的内存空间分为2块,每次只使用一块。在垃圾回收时,将正在使用的内存中的存货对象复制到没有被使用的内存中的一块。完成之后,清除正在使用的内存块中的所有独享,然后两个内存块交换,以此完成垃圾回收。
4.4 标记压缩算法
标记压缩算法是在标记清除算法的基础之上优化,从根节点开始,对对象的可达性做一次扫描标记,之后不是直接清除未标记的对象,而是将所有的存货对象压缩到内存的一端,之后,清除边界外的所有空间。
4.5 分区算法
分区算法是将整个堆空间分成连续的不同的小区间,每一个小区间都是独立使用,独立回收。这样设计可以控制一次回收多少个区间,不会去全扫描。
参考:
《GC垃圾回收算法》
《垃圾回收(GC)的三种基本方式》
5. 常见的JVM性能监控和故障处理工具类:jps、jstat、jmap、jinfo、jconsole等
常见有五个命令行工具:
- jinfo:可以输出并修改运行时的java进程的opts。
- jps:与unix上的ps类似,用来显示本地的java进程,可以查看本地运行着几个java程序,并显示他们的进程号。
- jstat:一个极强的监视VM内存工具。可以用来监视VM内存内的各种堆和非堆的大小及其内存使用量。
- jmap:打印出某个Java进程内存内的,所有对象的情况(产生哪些对象,及其数量)
- jconsole:一个Java GUI监视工具,可以以图表化的形式显示各种数据,可以通过远程连接监视远程的服务器VM。
参考:
《JDK自带监控工具 jps、jinfo、jstat、jmap、jconsole》
6. JVM如何设置参数
JVM的参数可以在如下位置设置:
- 集成开发环境(IDE)下启动并使用JVM,如eclipse需要修改根目录文件eclipse.ini;
- Windows服务器下安装版的Tomcat,可以使用tomcat目录下Tomcat7w.exe和直接修改注册表两种方式修改JVM参数;
- 解压版本的tomcat,通过startup.bat启动tomcat加载配置的,在tomcat的bin下catalina.bat文件内添加
- Linux服务器Tomcat设置JVM,需要修改TOMCAT_HOME/bin/catalina.sh
参考:
《jvm参数在哪里设置》
7. JVM性能调优
为了充分利用高性能服务器的硬件资源,有两种JVM调优方法。
7.1 采用64位操作系统,并为JVM分配大内存
JVM中堆内存太小的话,就会频繁地发生垃圾回收,而垃圾回收都会伴随不同程度的程序停顿,因此,如果扩大堆内存的话可以减少垃圾回收的频率,从而避免程序的停顿。
32位操作系统理论上最大只支持4G内存,64位操作系统最大能支持128G内存,因此我们可以使用64位操作系统,并使用64位的JVM,为JVM分配更大的堆内存。
堆内存变大后,虽然垃圾收集的频率减少了,但每次垃圾回收的时间变长。如果堆内存为14G,那么每次Full GC将长达数十秒。如果Full GC频繁发生,那么对于一个网站来说是无法忍受的。
因此,对于使用大内存的程序来说,一定要减少Full GC的频率,如果每天只有一两次Full GC,而且发生在半夜,那就可以接受。
要减少 Full GC的频率,就需要尽量避免太多对象进入老年代:
- 确保对象都是朝生夕死的:一个对象使用完后尽快让它失效,然后尽快在新生代中被Minor GC回收掉,尽量避免对象在新生代中停留太长时间。
- 提高大对象直接进入老年代的门槛:通过设置参数 -XX:PretrnureSizeThreshold来提高大独享的门槛,尽量让对象都先进入新生代,然后尽快被Minor GC回收掉,而不要直接进入老年代。
7.2 使用32位JVM集群
针对64位JDK的种种弊端,我们更多选择使用32位JDK集群来充分利用高性能机器的硬件资源。
在一台服务器上运行多个服务器程序,这些程序都运行在32位的JDK上。然后再运行个服务器作为反向代理服务器,由它来实现负载均衡。
由于32位JDK最多支持2G内存,因此每个虚拟节点的堆内存可以分配1.6G,一共运行10个虚拟节点多的话,这台物理服务器可以拥有16G的堆内存。
缺点:
- 多个虚拟节点竞争共享资源时容易出现问题:例如多个虚拟节点共同竞争IO操作,很可能会引起IO异常。
- 很难高效地使用资源池:如果每个虚拟节点使用各自的资源池,那么无法实现各个资源池的负载均衡。如果使用集中式资源池,那么又存在竞争的问题。
- 每个虚拟节点最大内存为2G。
以上都属于宏观调优,是尽可能利用机器的性能来达到JVM性能最大化。而如果机器性能已经达到极限了呢?我们只能通过各种JDK的监控工具来查看具体的内存分配,线程运行,对象创建情况等,来尽可能找到性能瓶颈,只有了解了性能瓶颈究竟在哪里才能做出最佳的调优方案。
而JVM各种参数中,说几个常用的:
- Xms:设定程序启动时占用内存的大小。一般来说越大程序启动越快,但也可能会导致机器暂时变慢。
- Xmx:设定程序运行期间最大可占用内存的大小。如果程序运行需要占用更多的内存,超过了这个设置值,就会抛出OutOfMemory异常。
- Xss:设置每个线程的堆栈大小。这个就要依据程序,看一个线程大约需要占用多少内存,可能会有多少线程同时运行等。
参考:
《深入理解JVM(六)——JVM性能调优实战》
《JVM性能调优总结》
8. 类加载器、双亲委派模型
类加载器ClassLoader是Java语言的一项创新,也是Java流行的一个重要原因。在类加载的第一阶段“加载”过程中,需要通过一个类的全限定名来获取定义此类的二进制字节流,完成这个动作的代码块就是类加载器。这一动作是放在Java虚拟机外部去实现的,以便让应用程序自己决定如何获取所需的类。
类加载器的作用不仅仅是实现类的加载,它还与类的相等判定有关。关系着Java相等判断方法的返回结果。只有在满足如下三个类相等判定条件,才能判定两个类相等。
- 两个类来自同一个Class文件
- 两个类是由同一个虚拟机加载
- 两个类是由同一个类加载器加载
类加载器的分类:
- Bootstrap ClassLoader:启动类加载器(跟类加载器),它负责加载Java的核心类库,加载如JAVA_HOME/lib目录下的rt.jar(包含System,String等这样的核心类)。这样的核心类库。跟类加载器非常特殊,他不是java.lang.ClassLoader的子类,它是JVM自身内部由C/C++实现的,并不是Java实现的。
- Extension ClassLoad:扩展类加载器,它负责加载扩展目录JAVA_HOME/jre/lib/ext下的jar包,用户可以把自己开发的类打包成jar包放在这个目录下即可扩展核心类以外的新功能。
- System ClassLoader/App ClassLoader:系统类架子啊器或应用程序类加载器,是加载classpath环境变量所指定的jar包与类路径。一般来说,用户自定义的类就是有App ClassLoader加载的。
除此之外,还有自定义的类加载器,它们之间的层次关系被称为类加载器的双亲委派模型。该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类记载器,而这种父子关系一案板通过组合关系来实现,而不是通过继承。
某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
使用双亲委托模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载德华,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那么系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但永远无法被加载运行。
在java.lang.ClassLoader的loadClass()方法中,会先检查是否已经被加载过,如果没有加载则调用父类加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
注意,双亲委派模型是Java设计者推荐给开发者的类加载器的实现方式,但并不是强制规定的。大多数的类记载器都遵循这个模型,但是JDK中也有较大规模破坏双亲模型的情况,例如线程上下文类加载器(Thread COntext ClassLoader)的出现。
参考:
《【深入理解JVM】:类加载器与双亲委派模型》
《JVM类加载机制详解(二)类加载器与双亲委派模型》
9. 类加载的过程:加载、验证、准备、解析、初始化
JVM类加载过程分为加载,验证,准备,解析,初始化,使用,卸载七个阶段。这些阶段通常都是互相交叉的混合式进行的,通常会在一个阶段执行的过程中调用或激活另一个阶段。
9.1 加载Loading
加载是类加载过程的第一个阶段。在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区的这个类的各种数据的访问入口。
9.2 验证Verification
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并不会危害虚拟机的自身安全。
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
9.3 准备Preparation
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量。实例变量会在对象实例化的时候随着对象一起分配在Java堆中。
如果类字段的字段属性表中存在ConstantValue属性(被final所修饰的变量),name在准备阶段value就会被初始化为ConstantValue属性所指定的值。
9.4 解析Resolution
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用Symbolic References:符号引用以一组符号来描述锁引用的目标。符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用Direct References:直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那么引用的目标一定是已经存在于内存中。
9.5 初始化Initialization
类初始化阶段是类加载过程的最后一步。到了这个阶段才真正开始执行类中定义的Java程序代码(或者是字节码)。在准备阶段,变量已经赋过一次系统要求的初始值;而在初始化阶段,则根据程序员通过程序制定的助管计划去初始化变量和其他资源。
参考:
《JAVA虚拟机(JVM)——类加载的过程(加载、验证、准备、解析、初始化)》
《JVM类加载过程分析及验证》
10. 强引用、软引用、弱引用、虚引用
从JDK1.2版本开始,把对象的引用分为四种级别,从而使程序能更加灵活的控制对象的生命周期。
10.1 强引用
我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。所有new出来的都是强引用。如果一个对象具有强引用,当内存空间不足时,JVM宁可抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
10.2 软引用SoftReference
如果一个对象只具有软引用,当内存空间即将不足时,GC就会回收这些对象的内存。而当内存空间足够时,GC则不会回收这些对象。
软引用可用来实现内存敏感大的高速缓存。可以和一个引用队列ReferenceQueue联合使用。如果软引用锁引用的对象被垃圾回收,JVM就会把这个软引用加入到与之关联的引用队列中。当内存足够大时可以把数组存入软引用,取数据时就可以从内存中取数据,提高运行效率。
10.3 弱引用WeakReference
弱引用与软引用基本相似,但只具有弱引用的对象拥有更短暂的生命周期。在GC扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管对当前内存空间足够与否,都会回收他的内存。不过由于GC是一个优先级很低的线程,因此不一定这些只有软引用的对象会被及时回收。
弱引用可以用来在回调函数中防止内存泄露。
10.4 虚引用PhantomReference
虚引用与其他几种引用都不同,并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用弱引用的一个区别在于:虚引用必须和引用队列联合使用。当垃圾回收期准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把整个虚引用加入到与之关联的引用队列中,程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
在实际程序设计中很少使用弱引用和虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
参考:
《Java 关于强引用,软引用,弱引用和虚引用的区别与用法》
11. Java内存模型JMM
内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象,不同架构下的物理机拥有不一样的内存模型,Java虚拟机也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。在C/C++语言中直接使用物理硬件和操作系统内存模型,导致不同平台下并发访问出错。而JMM的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,使得Java程序能够“一次编写,到处运行”。
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。此处的变量与Java编程时所说的变量不一样,指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。
Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面讲的处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。
JMM是围绕着在并发过程中如何处理可见性,原子性,有序性这三个特性而建立的模型。
可见性: JMM提供了volatile变量定义,final。synchronized块来保证可见性
原子性:JMM提供保证了访问基本数据类型的原子性,但实际业务处理场景往往需要更大的范围的原子性保证,所以模型也提供了synchronized块来保证。
有序性:HMM提供了volatile和synchronized来保证线程之间操作的有序性。
硬件层提供了一系列的内存屏障来提供一致性的能力。它可以阻止屏障两边的指令重新排序,也强制把写缓冲区/高速缓存中的脏数据鞥写回主内存,让缓存中相应的数据失效。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
参考:
《深入理解java虚拟机》
《JMM——volatile与内存屏障》
《JMM简介》
12. 堆分为哪几块,比如说新生代老生代,那么新生代又分为什么?
堆分为新生代和老年代,而新生代又分为Eden区和两个幸存区(From和To)。
每个new出的对象都会进入Eden区,直到发生GC,GC后GC根可达对象将会进入Survior区,而垃圾对象将被GC回收。而当经过多次GC后仍然存活的对象将会进入老年区。
新生去对象产生较多且大多是朝生夕灭,所以直接采用标记-清理算法进行GC回收。而老年区的对象被认定为存活几率极大的对象,GC采用复制算法。
《Java堆空间的划分:新生代、老年代》