现在的位置: 首页 > 综合 > 正文

Java学习整理系列之Java内存管理及垃圾回收

2018年02月06日 ⁄ 综合 ⁄ 共 8771字 ⁄ 字号 评论关闭

一、java内存模型

Java虚拟机会将内存分为几个不同的管理区,这些区域各自有各自的用途,根据不同的特点,承担不同的任务以及在垃圾回收时运用不同的算法。总体分为下面几个部分:程序计数器(Program Counter Register)、Java虚拟机栈(JVM Stack)、本地方法栈(Native Method Stack)、堆(Heap)、方法区(Method Area),如下图:




1、程序计数器(Program Counter Register)

这是一块比较小的内存,不在Ram上,而是直接划分在CPU上的,程序员无法直接操作它,它的作用是:JVM在解释字节码文件(.class)时,存储当前线程所执行的字节码的行号,只是一种概念模型,各种JVM所采用的方式不同,字节码解释器工作时,就是通过改变程序计数器的值来选取下一条要执行的指令,分支、循环、跳转、等基础功能都是依赖此技术区完成的。还有一种情况,就是我们常说的Java多线程方面的,多线程就是通过现程轮流切换而达到的,同一时刻,一个内核只能执行一个指令,所以,对于每一个程序来说,必须有一个计数器来记录程序的执行进度,这样,当现程恢复执行的时候,才能从正确的地方开始,所以,每个线程都必须有一个独立的程序计数器,这类计数器为线程私有的内存。如果一个线程正在执行一个Java方法,则计数器记录的是字节码的指令的地址,如果执行的一个Native方法,则计数器的记录为空,此内存区是唯一一个在Java规范中没有任何OutOfMemoryError情况的区域。

2、Java虚拟机栈(JVM Stack)

JVM虚拟机栈就是我们常说的堆栈的栈(我们常常把内存粗略分为堆和栈),和程序计数器一样,也是线程私有的,生命周期和线程一样,每个方法被执行的时候会产生一个栈帧,用于存储局部变量表、动态链接、操作数、方法出口等信息。方法的执行过程就是栈帧在JVM中出栈和入栈的过程。局部变量表中存放的是各种基本数据类型,如boolean、byte、char、等8种,及引用类型(存放的是指向各个对象的内存地址),因此,它有一个特点:内存空间可以在编译期间就确定,运行期不在改变。这个内存区域会有两种可能的Java异常:StackOverFlowError和OutOfMemoryError。

 栈帧(stack frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。 每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。对于执行引擎来说,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

(1)局部变量表
      局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
      局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放一个32位以内的数据类型(boolean、byte、char、short、int、float、reference和returnAddress八种)。reference类型虚拟机规范没有明确说明它的长度,但一般来说,虚拟机实现至少都应当能从此引用中直接或者间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。returnAddress类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
      虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),那么局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过this访问。
      Slot是可以重用的,当Slot中的变量超出了作用域,那么下一次分配Slot的时候,将会覆盖原来的数据。Slot对对象的引用会影响GC(要是被引用,将不会被回收)。
      系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。也就是说不存在类变量那样的准备阶段。
 (2)操作数栈
      Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操作数栈。操作数栈也常被称为操作栈。
     和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
      虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。
      虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。比如,iadd指令就要从操作数栈中弹出两个整数,执行加法运算,其结果又压回到操作数栈中,看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:

begin
iload_0    // push the int in local variable 0 onto the stack
iload_1    // push the int in local variable 1 onto the stack
iadd       // pop two ints, add them, push result
istore_2   // pop int, store into local variable 2
end

在这个字节码序列里,前两个指令iload_0和iload_1将存储在局部变量中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置。

3、本地方法栈(Native Method Stacks)

从名字即可看出,本地方法栈就是用来处理Java中的本地方法的,Java类的祖先类Object中有众多Native方法,如hashCode()、wait()等,他们的执行很多时候是借助于操作系统,但是JVM需要对他们做一些规范,来处理他们的执行过程。此区域,可以有不同的实现方法,向我们常用的Sun的JVM就是本地方法栈和JVM虚拟机栈是同一个。

4、堆(Heap)

堆内存是内存中最重要的一块,也是最有必要进行深究的一部分。因为Java性能的优化,主要就是针对这部分内存的。所有的对象实例及数组都是在堆上面分配的(随着JIT技术的逐渐成熟,这句话视乎有些绝对,不过至少目前还基本是这样的),可通过-Xmx和-Xms来控制堆的大小。JIT技术的发展产生了新的技术,如栈上分配和标量替换,也许在不久的几年里,即时编译会诞生及成熟,那个时候,“所有的对象实例及数组都是在堆上面分配的”这句话就应该稍微改改了。堆内存是垃圾回收的主要区域,所以在下文垃圾回收板块会重点介绍,此处只做概念方面的解释。在32位系统上最大为2G,64位系统上无限制。可通过-Xms和-Xmx控制,-Xms为JVM启动时申请的最小Heap内存,-Xmx为JVM可申请的最大Heap内存。

5、方法区(Method Area)

 方法区是所有线程共享的内存区域,用于存储已经被JVM加载的类信息、常量、静态变量等数据,一般来说,方法区属于持久代(关于持久代,会在GC部分详细介绍,除了持久代,还有新生代和旧生代),也难怪Java规范将方法区描述为堆的一个逻辑部分,但是它不是堆。方法区的垃圾回收比较棘手,就算是Sun的HotSpot VM在这方面也没有做得多么完美。此处引入方法区中一个重要的概念:运行时常量池。主要用于存放在编译过程中产生的字面量(字面量简单理解就是常量)和引用。一般情况,常量的内存分配在编译期间就能确定,但不一定全是,有一些可能就是运行时也可将常量放入常量池中,如String类中有个Native方法intern()<关于intern()的详细说明。

6、直接内存(Direct Memory)

此处补充一个在JVM内存管理之外的一个内存区:直接内存。在JDK1.4中新加入类NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,即我们所说的直接内存,这样在某些场景中会提高程序的性能。

二、垃圾回收

有句话说的好:Java和C++之间有一堵有内存分配和垃圾回收技术围成的墙,墙外的人想进去,墙里的人想出去!这句话的意思,请读者自己去琢磨。总的来说,C、C++程序员有时苦于内存泄露,内存管理是件令人头痛的事儿,但是Java程序员呢,又羡慕C++程序员,自己可以控制一切,这样就不会在内存管理方面显得束手无策,的却如此,作为Java程序员我们很难去控制JVM的内存回收,只能根据它的原理去适应,尽量提高程序的性能。下面开始讲解Java垃圾回收,即Garbage Collection,GC。从以下四个方面进行:

1、为什么要进行垃圾回收?

随着程序的运行,内存中存在的实例对象、变量等信息占据的内存越来越多,如果不及时进行垃圾回收,必然会带来程序性能的下降,甚至会因为可用内存不足造成一些不必要的系统异常。

2、哪些“垃圾”需要回收?

在我们上面介绍的五大区中,有三个是不需要进行垃圾回收的:程序计数器、JVM栈、本地方法栈。因为它们的生命周期是和线程同步的,随着线程的销毁,它们占用的内存会自动释放,所以只有方法区和堆需要进行GC。具体到哪些对象的话,简单概况一句话:如果某个对象已经不存在任何引用,那么它可以被回收。通俗解释一下就是说,如果一个对象,已经没有什么作用了,就可以被当废弃物被回收了。

3、什么时候进行垃圾回收?

根据一个经典的引用计数算法,每个对象添加一个引用计数器,每被引用一次,计数器加1,失去引用,计数器减1,当计数器在一段时间内保持为0时,该对象就认为是可以被回收得了。但是,这个算法有明显的缺陷:当两个对象相互引用,但是二者已经没有作用时,按照常规,应该对其进行垃圾回收,但是其相互引用,又不符合垃圾回收的条件,因此无法完美处理这块内存清理,因此Sun的JVM并没有采用引用计数算法来进行垃圾回收。而是采用一个叫:根搜索算法,如下图:

基本思想就是:从一个叫GC Roots的对象开始,向下搜索,如果一个对象不能到达GC Roots对象的时候,说明它已经不再被引用,即可被进行垃圾回收(此处 暂且这样理解,其实事实还有一些不同,当一个对象不再被引用时,并没有完全“死亡”,如果类重写了finalize()方法,且没有被系统调用过,那么系统会调用一次finalize()方法,以完成最后的工作,在这期间,如果可以将对象重新与任何一个和GC
Roots有引用的对象相关联,则该对象可以“重生”,如果不可以,那么就说明彻底可以被回收了),如上图中的Object5、Object6、Object7,虽然它们3个依然可能相互引用,但是总体来说,它们已经没有作用了,这样就解决了引用计数算法无法解决的问题。

补充引用的概念JDK 1.2之后,对引用进行了扩充,引入了强、软、若、虚四种引用,被标记为这四种引用的对象,在GC时分别有不同的意义:

    a> 强引用(Strong Reference).就是为刚被new出来的对象所加的引用,它的特点就是,永远不会被回收。

    b> 软引用(Soft Reference).声明为软引用的类,是可被回收的对象,如果JVM内存并不紧张,这类对象可以不被回收,如果内存紧张,则会被回收。此处有一个问题,既然被引用为软引用的对象可以回收,为什么不去回收呢?其实我们知道,Java中是存在缓存机制的,就拿字面量缓存来说,有些时候,缓存的对象就是当前可有可无的,只是留在内存中如果还有需要,则不需要重新分配内存即可使用,因此,这些对象即可被引用为软引用,方便使用,提高程序性能。

    c> 弱引用(Weak Reference).弱引用的对象就是一定需要进行垃圾回收的,不管内存是否紧张,当进行GC时,标记为弱引用的对象一定会被清理回收。

    d> 虚引用(Phantom Reference).虚引用弱的可以忽略不计,JVM完全不会在乎虚引用,其唯一作用就是做一些跟踪记录,辅助finalize函数的使用。

最后总结,什么样的类需要回收呢?无用的类,何为无用的类?需满足如下要求:

   1> 该类的所有实例对象都已经被回收。

   2> 加载该类的ClassLoader已经被回收。

   3> 该类对应的反射类java.lang.Class对象没有被任何地方引用。

4、如何进行垃圾回收?

本块内容以介绍垃圾回收算法为主,因为我们前面有介绍,内存主要被分为三块,新生代、旧生代、持久代。三代的特点不同,造就了他们所用的GC算法不同,新生代适合那些生命周期较短,频繁创建及销毁的对象,旧生代适合生命周期相对较长的对象,持久代在Sun HotSpot中就是指方法区(有些JVM中根本就没有持久代这中说法)。首先介绍下新生代、旧生代、持久代的概念及特点:

新生代:New Generation或者Young Generation。上面大致分为Eden区和Survivor区,Survivor区又分为大小相同的两部分:FromSpace 和ToSpace。新建的对象都是用新生代分配内存,Eden空间不足的时候,会把存活的对象转移到Survivor中,新生代的大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例.
旧生代:Old Generation。用于存放新生代中经过多次垃圾回收仍然存活的对象,例如缓存对象。旧生代占用大小为-Xmx值减去-Xmn对应的值。

持久代:Permanent Generation。在Sun的JVM中就是方法区的意思,尽管有些JVM大多没有这一代。主要存放常量及类的一些信息默认最小值为16MB,最大值为64MB,可通过-XX:PermSize及-XX:MaxPermSize来设置最小值和最大值。

常见的GC算法:

标记-清除算法(Mark-Sweep)

最基础的GC算法,将需要进行回收的对象做标记,之后扫描,有标记的进行回收,这样就产生两个步骤:标记和清除。这个算法效率不高,而且在清理完成后会产生内存碎片,这样,如果有大对象需要连续的内存空间时,还需要进行碎片整理,所以,此算法需要改进。

复制算法(Copying)

前面我们谈过,新生代内存分为了三份,Eden区和2块Survivor区,一般Sun的JVM会将Eden区和Survivor区的比例调为8:1,保证有一块Survivor区是空闲的,这样,在垃圾回收的时候,将不需要进行回收的对象放在空闲的Survivor区,然后将Eden区和第一块Survivor区进行完全清理,这样有一个问题,就是如果第二块Survivor区的空间不够大怎么办?这个时候,就需要当Survivor区不够用的时候,暂时借持久代的内存用一下。此算法适用于新生代

标记-整理(或叫压缩)算法(Mark-Compact)

和标记-清楚算法前半段一样,只是在标记了不需要进行回收的对象后,将标记过的对象移动到一起,使得内存连续,这样,只要将标记边界以外的内存清理就行了。此算法适用于持久代

常见的垃圾收集器: 

根据上面说的诸多算法,每天JVM都有不同的实现,我们先来看看常见的一些垃圾收集器:

首先介绍三种实际的垃圾回收器:串行GC(SerialGC)、并行回收GC(Parallel Scavenge)和并行GC(ParNew)。

1、Serial GC。是最基本、最古老的收集器,但是现在依然被广泛使用,是一种单线程垃圾回收机制,而且不仅如此,它最大的特点就是在进行垃圾回收的时候,需要将所有正在执行的线程暂停(Stop The World),对于有些应用这是难以接受的,但是我们可以这样想,只要我们能够做到将它所停顿的时间控制在N个毫秒范围内,大多数应用我们还是可以接受的,而且事实是它并没有让我们失望,几十毫米的停顿我们作为客户机(Client)是完全可以接受的,该收集器适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。

2、ParNew GC。基本和Serial GC一样,但本质区别是加入了多线程机制,提高了效率,这样它就可以被用在服务器端(Server)上,同时它可以与CMS GC配合,所以,更加有理由将它置于Server端。

3、Parallel Scavenge GC。在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。以下给出几组使用组合:


4、CMS (Concurrent Mark Sweep)收集器。该收集器目标就是解决Serial GC 的停顿问题,以达到最短回收时间。常见的B/S架构的应用就适合用这种收集器,因为其高并发、高响应的特点。CMS收集器是基于“标记-清除”算法实现的,整个收集过程大致分为4个步骤:

初始标记(CMS initial mark)、并发标记(CMS concurrenr mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)。

其中初始标记、重新标记这两个步骤任然需要停顿其他用户线程。初始标记仅仅只是标记出GC ROOTS能直接关联到的对象,速度很快,并发标记阶段是进行GC ROOTS 根搜索算法阶段,会判定对象是否存活。而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器的优点:并发收集、低停顿,但是CMS还远远达不到完美。

CMS收集器主要有三个显著缺点

a>.CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。

b>.CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提供触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent
Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的过高将会很容易导致“Concurrent Mode Failure”失败,性能反而降低。

c>.最后一个缺点,CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full
GC之后,跟着来一次碎片整理过程。

5、G1收集器。相比CMS收集器有不少改进,首先基于标记-整理算法,不会产生内存碎片问题,其次,可以比较精确的控制停顿,此处不再详细介绍。

6、Serial Old。Serial Old是Serial收集器的老年代版本,它同样使用一个单线程执行收集,使用“标记-整理”算法。主要使用在Client模式下的虚拟机。

7、Parallel Old。Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。

抱歉!评论已关闭.