众所周知,在Java中内存的分配和回收都是由java虚拟机(JVM)操作的,我们并不需要像C/C++那样需要手动分配和释放内存,也正是因为有了JVM,才能使跨平台成为了可能。说道垃圾回收,我们先要知道在java中内存有哪些部分和作用。
一、java内存组成
在JVM中,内存主要分为一下几个部分:程序计数器(PC)、方法栈、本地方法栈、堆、方法区
1、程序计数器:
操作系统在线程切换和恢复的时候,必须要记录任务执行的位置,这样,当任务恢复的时候,才知道该从哪里继续执行,程序计数器就是记录程序执行的位置的,可以简单理解成字节码的行号指示器。
因为每个线程都是独立执行的,所以每个线程都有一个程序计数器,意味着,程序计数器是线程私有的。
对于java方法来说,程序计数器记录的是字节码的指令地址,对于native方法来说,它的值为空。
程序计数器是唯一一个不会发生OOM(out of memory)的地方。
2、方法栈
方法栈也叫作虚拟机栈,对每个即将执行的方法,都会为其创建一个栈帧,对于java方法从开始执行,到执行结束,就是一个入栈到出栈的过程。
方法栈包含局部变量表,操作数栈,方法退出地址等。
方法栈是线程私有的。
当内存不够创建新的栈帧的时候,会抛出OutOfMemoryError;当方法栈调用深度超过栈的最大深度时,就会出现StackOverflowError,可以使用-Xss设置栈的大小。
3、本地方法栈
本地方法栈和方法栈很类似,但是只是执行native方法时才会使用,JVM规范并没有对本地方法栈的实现有强制性要求,所以会根据每个JVM的不同有不同的实现。本地方法栈也是线程私有的,同样会抛出OutOfMemoryError和StackOverflowError。
4、方法区
JVM在java类在加载后,会将class文件代表的静态存储结构转换为方法区的可运行结构,并将该结构存储在方法区中。主要存储的是类信息,常量池,类变量和JIT编译后的数据。常量池主要包括字面量和符号引用。字面量比较接近java中常量的概念,比如字符串,final修饰的数据等,而符号引用只要是类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。
方法区中的数据是共享的,对所有线程可见。
常量池在运行时也可以改变,比如调用字符串的intern()方法。
当内存不够分配新的常量空间的时候,也会抛出OutOfMemoryError。
5、堆
堆主要用来存储新对象和数组,前面4部分的内存都是由JVM进行管理和控制,对于开发人员来说,堆是可操作性最高的部分,当创建新的对象时,JVM会根据类信息在堆中分配一块内存给新的对象,当内存不够时,也会抛出OutOfMemoryError,同时,这部分区域也是垃圾收集器重要的收集对象。
二、垃圾收集算法
分配内存很好理解,只要在需要分配内存的地方申请相应的地址空间就可以,但是,内存回收就没这么简单了,需要在程序运行过程中判断对象是否还有用,对于没有用的对象,需要对其占用的空间进行回收。这里我们简单介绍下各种垃圾回收算法及其优劣。
1、引用计数法
引用计数法就是记录对象的引用,用来判断对象是否还有用。对于一个对象,当有别的地方访问它时,引用计数器数值+1,当不再引用它时,计数器-1,当对象的引用计数器数值为0的时候,说明该对象不再被使用,可以被回收。引用计数法实现很简单,只需要对每个对象增加一个计数器即可,但是,对于对象间的循环引用就无能为力了。
2、标记清除算法
为了解决对象间循环引用的问题,有了标记-清除算法,就像它的名字一样,等需要开始垃圾回收的时候,从GC Root开始,GC线程会把引用不到的对象标记出来(循环引用的对象自然就会被标记出来了),然后就把标记过的对象清除,这样就可以释放无用对象所占用的内存了。但是,这里面有一个很严重问题-内存碎片化严重,这样会严重影响内存的利用率。
举个栗子,现在创建一个需要100MB内存的对象,但是内存只剩20MB,不够,所以触发了GC并且清理了120个对象腾出100MB的内存,按说现在120MB的内存已经够了,但多数情况下还是会抛出OOM异常,因为这些对象不是连续存储的,所以清理出来的内存也不是连续的,由于不存在连续的100MB的内存,所以会抛异常。
3、复制算法
复制算法为了解决标记清除算法内存碎片化的问题。复制算法主要工作流程为:
将内存按照一定比例分为两部分(通常为五五分),对象在只其中的一部分创建
在触发GC后,从GC Root开始将能够引用到的对象(有效对象)依次复制到另外一部分
完成所有有效对象的复制后,清除这部分内存
下次GC又从另外一部分内存复制到当前部分内存,清除另一部分内存
这样,内存碎片化的问题就解决了,但是,你会发现,同一时间其实只有一部分内存在使用,另外一部分都是在空闲的,这样导致内存利用率很低(五五分情况下内存利用率最多到50%)
4、标记整理算法
标记整理算法是为了解决标记清除算法内存碎片化和复制算法内存利用率低的问题,它其实也是这两种算法的结合。工作流程如下:
触发GC后,从GC Root开始将有效对象标记出来
将有效对象依次移动到可用内存开始处
将剩余部分内存清
目前看来,标记整理算法解决了前面几种方法的弊端,也没有引入比较严重的新的问题,那么是不是就没有任何问题,成为垃圾收集的银弹了呢?答案是:当然不是。原因在于,前面所说的垃圾回收的过程中,除了GC线程外,jvm会停止所有java程序的运行,在jvm领域也称为stop the world,这样做的好处和坏处都显而易见,好处就是减少了GC的复杂性,坏处就是java应用都被暂时停止,直到GC结束。在GC算法发展的过程中,细心的人会发现,有些对象的生存周期很长,甚至和整个应用的生命周期相同,比如方法区中的类的字节码所对应的结构,以及堆中代表类的Class对象;有的对象生命周期很短,比如方法中申明的变量所引用的对象,在方法结束后即失效。这种根据生命周期长短划分对象,接近应用的生命周期的叫做永久代,生命周期长的叫做老年代,生命周期短的叫做新生代。
5、分代算法
其实分代算法不是一个具体的垃圾回收算法,它只是根据对象生命周期的长短,选择不同的回收算法。比如说,复制算法,如果大多数都是新生代对象,每次复制的对象会很少,那么效率就会比较高;相反,如果大多数对象属于老年代或者永久代,那么每次需要复制的对象就会特别多,效率就会很低。比如标记复制或者标记整理算法,如果是老年代居多,那么每次需要复制或者整理的对象就比较少,相反,如果是新生代居多,需要复制或者整理的内存就会很多,效率自然就低很多。
所以分代算法主要就是根据生命周期的长短选择合适的算法。一般来说,新生代居多合适用复制算法,老年代适合标记复制算法,永久代适合标记整理算法。
所以说在实际应用过程中,应该根据应用的特点选择合适的垃圾收集算法。