java运行时数据区域分为五个部分,有的部分都随着虚拟机的启动而一直存在,有的区域则是依赖用户线程的启动和结束而建立和销毁。
可以看作是当前线程所执行字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。也是程序控制流的指示器(分支,循环,跳转,异常处理,线程恢复等)。程序计数器是线程独立的,各线程之间互不影响,独立存储,属于线程私有内存。对于多并发的情况:通过线程轮流切换,分配处理器执行时间的方式来实现,任何情况下一个处理器都只会执行一条程序中的指令。程序计数器是唯一一个在java虚拟机规范中没有规定任何OutOfMemery情况的区域.
也是线程私有。描述的是java方法执行的线程内存模型。每个方法被执行的时候java虚拟机都会同步创建一个栈桢用于储存局部变量表,操作数栈,动态链接,方法出口等信息。每个方法被调用知道执行完毕的过程,就对应着一个栈桢在虚拟机栈中从入栈到出栈的过程。其中,64位长度的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,空间是完全确定的,在方法运行期不会改变局部变量表的大小。(各虚拟机真正用多大的内存空间来实现一个变量空间完全由虚拟机自己决定。)
其中,栈桢中的局部变量表是特别重要的一环:他存放了编译器可知的基本数据类型,对象引用,(指向对象起始地址的引用指针)和returnAddress类型(指向了一条字节码指令的地址)。
操作数栈的具体操作和结构:
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。第一个方法从调用开始到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到了方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。
一个线程中的方法调用链可能会很长,很多方法都同时处理执行状态。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如下图所示:
1.局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。
在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程,如果是实例方法,那么局部变量表中的每0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域来分配其余的Slot。局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法,如果当前字节码PC计算器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其它变量使用。
局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的。
2.操作数栈
操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。
当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠过程如下图:
3.动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。
4.方法返回地址
当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。
无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。
存本地方法。异常跟虚拟机栈一样。所有线程共享的区域
只能存放对象实例。所有的对象实例和数组都应当在堆上分配。是垃圾回收器要回收的区域,所以也被叫做gc堆。按垃圾分类分为新生代,老年代,永久代。(一会儿讲).
java堆既可以被实现为固定大小的,也可以是可扩展的(通过参数-Xmx和-Xms)。如果在堆上没有办法完成实例分配,并且堆也无法再扩展时,java虚拟机将会抛出OutOfMemory异常.
和堆一样,是各个线程共享的内存区域,用于存储已经被虚拟机加载的类型信息,常量,静态变量等等。虚拟机规范定义方法区不必实现垃圾回收,因为没有必要。运行时常量池是方法区的一部分,存了各种字面量和符号引用。
方法区无法获取新的内存分配需求时,将会抛出OutOfMemory异常.
首先:虚拟机遇到new指令时先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化过。如果没有,那就必须限制性相应的类加载过程。
然后,给对象分配内存,对象所需要的内存大小在类加载完成后便可以完全确定,分配内存就是把一块确定大小的内存块从JAVA堆中划分出来。这里有两个方法:指针碰撞(Serial,ParNew)和空闲链表(CMS)。采用哪种方式取决于所采用的垃圾回收期是否带有空间压缩整理能力。这一步骤会有并发问题,A对象分配内存的时候B对象又使用原来的指针来分配内存,造成数据不一致。可以采用CAS加上失败重试解决或者每个线程在java堆中预先分配一小块内存,成为本地缓存区,只有本地缓存用完了,才会需要同步锁定。
内存分配完成之后虚拟机必须将分配到的存储空间都初始化为0,这保证对象的实例字段在java的代码中可以不赋初始值就可以直接使用,是程序可以访问到这些字段的数据类型所对应的零值。
接下来,java设置这个对象是哪个类的实例,元数据信息,对象的哈希码,对象的GC分代年龄等信息。这些信息存放在对象头中。
接下来执行构造函数(构造对象的其他信息和状态信息),new指令之后会接着执行init方法,按照程序员的的意愿对对象进行初始化,这样一个真正科用得对象才算是完全被构造出来。
在HotSpot虚拟机里对象在堆中的布局可以画分为三个部分:对象头,实例数据,对齐填充。
对象头包括两类信息:
1:运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,官方称它为“Mark Word”,存储在一个动态定义的数据结构。
2:类型指针。即对象指向它的类型元数据的指针。java虚拟机通过这个指针来确定该对象是哪个类的实例。如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为数组的长度是不确定的,无法通过元数据中的信息推断出数组的大小。
实例数据:对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
对齐填充:不是必然存在的,没特殊含义,仅仅起着占位符的作用,因为HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍。
java程序通过栈上的reference数据来操作堆上的具体对象。但是<
主流的方式有句柄和直接指针两种。
句柄访问:java堆中可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与内存数据各自具体的地址信息。
直接指针访问:必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问开销。
各自的好处:
句柄:对象被移动的时候只会改变句柄中的实例数据指针,而reference本身不需要被修改。
直接指针:速度更快。HotSpot采用直接指针。
jvm方法被调用的过程,或者说程序执行的过程:
https://www.cnblogs.com/dingyingsi/p/3760730.html
AppMain.java
public class AppMain
//运行时, jvm 把appmain的信息都放入方法区
{
public static void main(String[] args) //main 方法本身放入方法区。
{
Sample test1 = new Sample( " 测试1 " ); //test1是引用,所以放到栈区里, Sample是自定义对象应该放到堆里面
Sample test2 = new Sample( " 测试2 " );
test1.printName();
test2.printName();
}
}
Sample.java
public class Sample //运行时, jvm 把appmain的信息都放入方法区
{
/** 范例名称 /
private name; //new Sample实例后, name 引用放入栈区里, name 值放入方法区
/* 构造方法 /
public Sample(String name)
{
this .name = name;
}
/* 输出 */
public void printName() //print方法本身放入 方法区里。
{
System.out.println(name);
}
}
OK,让我们开始行动吧,出发指令就是:“java AppMain”,包包里带好我们的行动向导图,Let’s GO!
系统收到了我们发出的指令,启动了一个Java虚拟机进程,这个进程首先从classpath中找到AppMain.class文件,读取这个文件中的二进制数据,然后把Appmain类的类信息存放到运行时数据区的方法区中。这一过程称为AppMain类的加载过程。
接着,Java虚拟机定位到方法区中AppMain类的Main()方法的字节码,开始执行它的指令。这个main()方法的第一条语句就是:
Sample test1=new Sample(“测试1”);
语句很简单啦,就是让java虚拟机创建一个Sample实例,并且呢,使引用变量test1引用这个实例。貌似小case一桩哦,就让我们来跟踪一下Java虚拟机,看看它究竟是怎么来执行这个任务的:
1、 Java虚拟机一看,不就是建立一个Sample实例吗,简单,于是就直奔方法区而去,先找到Sample类的类型信息再说。结果呢,嘿嘿,没找到@@,这会儿的方法区里还没有Sample类呢。可Java虚拟机也不是一根筋的笨蛋,于是,它发扬“自己动手,丰衣足食”的作风,立马加载了Sample类,把Sample类的类型信息存放在方法区里。
2、 好啦,资料找到了,下面就开始干活啦。Java虚拟机做的第一件事情就是在堆区中为一个新的Sample实例分配内存, 这个Sample实例持有着指向方法区的Sample类的类型信息的引用。这里所说的引用,实际上指的是Sample类的类型信息在方法区中的内存地址,其实,就是有点类似于C语言里的指针啦~~,而这个地址呢,就存放了在Sample实例的数据区里。
3、 在JAVA虚拟机进程中,每个线程都会拥有一个方法调用栈,用来跟踪线程运行中一系列的方法调用过程,栈中的每一个元素就被称为栈帧,每当线程调用一个方法的时候就会向方法栈压入一个新帧。这里的帧用来存储方法的参数、局部变量和运算过程中的临时数据。OK,原理讲完了,就让我们来继续我们的跟踪行动!位于“=”前的Test1是一个在main()方法中定义的变量,可见,它是一个局部变量,因此,它被会添加到了执行main()方法的主线程的JAVA方法调用栈中。而“=”将把这个test1变量指向堆区中的Sample实例,也就是说,它持有指向Sample实例的引用。
OK,到这里为止呢,JAVA虚拟机就完成了这个简单语句的执行任务。参考我们的行动向导图,我们终于初步摸清了JAVA虚拟机的一点点底细了,COOL!
接下来,JAVA虚拟机将继续执行后续指令,在堆区里继续创建另一个Sample实例,然后依次执行它们的printName()方法。当JAVA虚拟机执行test1.printName()方法时,JAVA虚拟机根据局部变量test1持有的引用,定位到堆区中的Sample实例,再根据Sample实例持有的引用,定位到方法区中Sample类的类型信息,从而获得printName()方法的字节码,接着执行printName()方法包含的指令。
https://www.cnblogs.com/qingergege/p/6853547.html
首先,明确一下,Java多态的三个必要条件:
1、 继承
2、 子类重写父类方法
3、 父类引用指向子类对象
然后看一个例子
package test.xing;
class Father{
protected int age;
public Father(){
age = 40;
}
void eat(){
System.out.println("父亲在吃饭");
}
}
class Child extends Father{
protected int age;
public Child(){
age = 18;
}
void eat(){
System.out.println("孩子在吃饭");
}
void play(){
System.out.println("孩子在打CS");
}
}
public class TestPolymorphic {
public static void main(String[] args) {
Father c = new Child();
c.eat();
//c.play();
System.out.println(“年龄:”+c.age );
}
}
输出结果为:
给出结论:当满Java多态的三个条件时,可以发现c.eat()调用的实际上是子类的eat,但c.age调用的还是父类的age,而c.play()则不会通过编译。
下面从JVM的角度解释上面这种现象
我们就从Father c = new Child()这句话切入
这句话首先会执行new Child(),在堆中分配一个对象。
当然在分配Child类的实例时,先要通过JVM的类加载器将Child类对应的class文件加载到JVM中,然后JVM根据class文件中的字节流产生一个表示class文件中类型信息的结构体
这个结构体的具体实现,不同的JVM会有不同的实现方式,但大致上都差不多,都要遵守JVM的规范。
这个表示class文件中类型信息的结构体大概由以下几部分构成:
1、 常量池
2、 类变量(静态变量)
3、 字段信息
4、 方法信息
5、 类的父类信息
6、 类的访问权限信息等
这些我说的也不够准确,具体的大家可以看JVM相关的书籍如《深入理解Java虚拟机》,在这里就有个大概的概念就好。
之后,JVM会根据上面这个结构体生成一个叫做方法表的东西。这个方法表是实现java多态的一个关键。
方法表中包含的是实例方法(就是相对于静态方法而言的,用对象访问的那些方法)的直接引用,也就是说通过这个方法表就能够访问到该类的实例方法,
而且,这些实例方法不仅包括本类的方法,还包括其父类的实例方法,以及父类的父类的实例方法(就是一直到Object)。
而且,这些方法中不包含私有方法(因为私有方法不能继承)
方法表中的这些直接应用会指向到JVM中表示类型信息的那个结构体(就是上面那个结构体)的相应的方法信息(就是上面结构体中4的某个位置),当然这只是本类的方法,表中还有父类的方法,相应地指向父类类型信息结构体的具体位置。
可能表达的不够清晰,下面画个图表示。
上面提到过,方法表中不仅包括本类的方法,还包括父类的方法,方法表值这样产生的,以Child类的方法表为例:
首先方法表中,会产生指向继承自Object类的方法的引用,这些包括指向toString的和指向equals的,当然Object中还包括很多方法,这里就不写了
然后方法表中产生指向继承自Parent类的方法的引用,这包括eat,
最后产生指向本类的方法的引用。
这里需要注意的一点是,当Child类的方法表产生指向Parent类中的方法的引用时,会有一个指向eat方法的引用,最后产生指向本类的方法的引用时,也有一个指向eat的引用,这时候,新的数据会覆盖原有的数据,也就是说原来指向Parent.eat的那个引用会被替换成指向Child.eat的引用(占据原来表中的位置)。所以我们看到在Child类的方法表中指向的是Child.eat而Parent类的方法表中指向的是Parent.eat。子类的方法表中就没有指向Parent.eat的引用了。
而且还要注意一个特点就是,Parent和Child的方法表中,指向eat的引用在表中的偏移量是一样的,都是第三个位置。(这是因为子类eat方法覆盖掉了父类eat方法,占据了原来父类eat方法的引用在表中的位置)
这里再多说一句,表示类型信息的结构体中,的方法信息,只包含本类特有的,或者是重写的方法信息,没有父类的方法信息。
了解了方法区的结构后,我们来看堆中对象的结构
接下来是栈区,产生Father类型的引用,这个引用指向堆区中的Child类的实例。
这里需要解释一下Father c的含义,我们知道c表示一个引用,这个引用指向堆中的Child类的实例,说白了就是一个地址,这个地址指向堆中的Child的类的实例,但是我们不要忘记前面还有一个Father修饰这个c
我们都知道在c中有void类型的指针,而给指针前面限定一个类型就限制了指针访问内存的方式,比如char * p就表示p只能一个字节一个字节地访问内存,但是int *p中p就必须四个字节四个字节地访问内存。
但是我们都知道指针是不安全的,其中一个不安全因素就是指针可能访问到没有分配的内存空间,也就是说char *虽然限制了p指针访问内存的方式,但是没有限制能访问内存的大小,这一点要完全靠程序员自己掌握。
但是在java的引用中Father不但指定了c以何种方式访问内存,也规定了能够访问内存空间的大小。
我们看Father实例对象的大小是占两行,但Child实例对象占三行(这里就是简单量化一下)。
所以虽然c指向的是Child实例对象,但是前面有Father修饰它,它也只能访问两行的数据,也就是说c根本访问不到Child类中的age!!!只能访问到Father类的age,所以输出40
而且我们注意两个类的方法表:
我们看到Parent的方法表占三行,Child的方法表占4行,c虽然指向了Child类的实例对象,而对象中也有指针指向Child类的方法表,但是由于c受到了Father的修饰,通过c也只能访问到Child方法表中前3行的内容!!!!
然而前面说过,在方法表的形成过程中,子类重写的方法会覆盖掉表中原来的数据,也就是Child类的方法表的第三行是指向Child.eat的引用,而不是指向Parent.eat(因为方法表产生了覆盖),所以c访问到的是Child.eat。也就是子类的方法!!!这种情况下,c是没有办法直接访问到父类的eat方法的。
1:引用计数:计数器值为0的对象不可能再被使用。
缺点:难以解决循环引用。
2:可达性分析算法:java默认,通过一系列的”GC Roots”的跟对象作为起始节点集,从这些节点开始,根据引用关系向下搜搜,搜索过程所走过的路径成为“引用链”,如果某个对象到GC ROots之间没有任何引用链相连,也就是这个对象不可达,则证明这个对象是不可能再被使用的。
在java技术体系中,固定的可作为GC Roots的对象包括以下几种:
1:在虚拟机栈中被引用的对象,如各个线程被调用的方法堆栈中使用到的参数,局部变量表,临时变量等。
2:在方法区中类静态属性引用的变量,如java类的引用类型静态变量。
3:在方法区中常量引用的对象,比如字符串常量池中的引用。
4:在本地方法栈中引用的对象。
5:java虚拟机内部的引用,比如基本数据类型对应的Class对象,一些常驻的异常对象,如NullPointException,还有系统类加载器。
6:被同步锁持有的对象。
7:反应java虚拟机栈内部情况的对象。
对象被标记为不可达后不是被马上回收,而是要看有没有必要执行finalize方法,这个方法只会执行一次,对象可以在这个方法里拯救自己。(不推荐使用).
对于方法区的回收是相当苛刻的,而且是低性价比的。
判断方法去中的一个类型是否属于“不再被使用的类”至少需要满足三个条件
1:该类的所有实例都已经被回收
2:该类的类加载器被回收
3:该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
其实就算满足了这三个条件,虚拟机也不会必然回收,想回收还要设置参数。
分代收集理论
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
根据这个理论,我们至少可以把堆分为两部分:
新生代:每次垃圾收时都会发现有大批对象死去,而每次回收后存货的少量对象,将会逐步晋升到老年代中存放。
老年代:主要存放应用程序中生命周期长的内存对象。
正常情况下,一个对象从创建到销毁,应该是从Eden,然后到Survivor Spaces(幸存区),再到Old Generation(年老代),最后在某次GC下消失。
当然,一个对象也可能直接在Eden里死掉,也可能一直在Old Generation(年老代)存活,这些都是有可能的。
关于跨代引用:存在相互引用的两个对象,是应该倾向于同事生存或者同时消亡的。
根据这条假说,我们就可以不再为了少量的跨带引用去扫描整个老年代,也不必为了专门记录每一个对象是否存在。我们只需要在新生代上建立一个记忆集,表示出老年代的哪一块儿内存会存在跨代引用。
各种GC名词:
MinorGC:新生代回收
MajorGC:老年代回收
Mixed GC指整个新生代回收和部分老年代回收
Full GC:整个堆区和方法区的回收
https://www.cnblogs.com/tuyang1129/p/12508216.html
1)标记—清除算法(Mark-Sweep)
标记—清除是以上上面三种算法中最基础的一种,为什么说它是最基础的,因为它的原理非常简单。故名思意,这个算法分为两个步骤:(1)标记;(2)清除。
• 标记:标记指的就是我们上面所说的可达性分析,采用之前所说的可达性分析算法遍历对象,所有不可达的对象将被标记为垃圾,等待回收;
• 清除:这一步很简单,直接释放垃圾对象所占内存空间;
这个算法有两个的问题:
1 效率较低,标记和清除这两个步骤的效率都比较低,清除的效率低是因为需要扫描整个内存空间,逐个释放对象所占内存;
2 使用这个算法清除垃圾后,将会造成很多内存碎片,所以可能出现剩余内存较多,但是没有较大的连续空间,导致大对象无法被分配空间,而再次触发垃圾回收;
我们通过两张对比图来看看这个算法的效果。通过下面这张图我们可以看到,在垃圾回收后造成了很多的内存碎片。
(2)复制算法(Copying)
为了解决效率较低以及产生内存碎片的问题,有人提出了一个新的算法——复制算法。这个算法的原理是:将内存分为两个相等大小的区域,一块存放对象,一块保留。当存放对象的那块区域无法再分配空间时,将所有仍然存活的对象复制到保留的那块区域中,然后直接释放当前正在使用区域的全部内存。这样一来,仍然存活的对象被放进保留区,而垃圾对象也被释放了。同时,之前被使用的空间被清空后,成了新的保留区,而之前的保留区成了被使用的空间,就这样不断循环使用两个空间。
我们之前提过,堆内存被分为新生代和老年代。在新生代中,每次垃圾回收都可以释放大量的对象,只有少部分存活,所以只有少部分对象要被复制到保留区中,这也意味着复制并不会太耗时。除此之外,直接释放被使用的空间的全部内存,比一段一段释放的效率也要高很多。同时,对象被复制到另外一个区域时,会被整齐地摆放,所以不会出现内存碎片,所以能够更简单地分配空间。所以,复制算法的效率要远远高于标记—清除算法。以下是一张复制算法的演示图:
但是,这里存在一个问题,复制算法将内存区域划分为相等的两部分,这也意味着每次都有一半的空间无法被使用,这未免也太浪费了。所以,对于空间的划分,需要做出一些改进。IBM公司的研究表明,98%的对象存活时间都非常的短暂,所以,完全没有必要保留一半的空间供复制使用。在实际实现中,会将空间划分为三块区域,一块较大的Eden空间,以及两块较小的Survivor空间。在为新对象分配空间时,首先会将其分配到Eden空间中,若Eden空间无法再分配空间时,将会触发垃圾回收,此时,会将Eden空间中的存活对象复制到其中一块Survivor空间中,然后清空Eden空间。当Eden空间再一次因无法分配空间而触发垃圾回收时,则会将Eden空间中的存活对象,以及上一次被复制进Survivor空间中的存活对象,都复制到另一块Survivor空间中,然后将Eden和上一块Survivor清空。也就是说,交替地使用两块Survivor空间,来存放垃圾回收中任然存活的对象。而在具体实现中,这三个空间的比例一搬是8:1:1,即是说只有10%的空间无法被使用。
可以看出,这个算法在大部分对象的生命周期都短时,效率会非常高,但是若大部分对象的生命周期都很长,将不再适用,所以这个算法一般只被用在新生代中。这里我们不得不考虑一个问题,当我们使用了上面说的将内存划分为三块的这种方式时,可能会出现一个问题:如果在某次垃圾回收过后,仍然有大量的对象存活,此时一个Survivor空间不够存放这些对象怎么办?这时候就需要有另一个空间来做担保了,当这种情况发生时,会将这些对象放入另一个空间中,那个空间就叫做担保空间。就像我们去银行贷款,需要有一个担保人,当贷款人不能偿还时,由担保人代为偿还。以上算法是用在新生代中,而所谓的担保空间,实际上就是老年代。老年代为这个算法提供了担保,但是在大部分情况下,Survivor都是能够满足需求的。
(3)标记—整理(Mark-Compact)
由于老年代中的对象一般存活时间都比较长,所以并不适合在老年代使用上面的复制算法进行垃圾回收。而有人根据老年代的特点,提出了标记—整理算法,注意看清楚,这里是整理,而不是第一种算法中的清除。这个算法也分为标记和整理两个步骤,标记这个步骤和第一个算法是一样的,关键是整理步骤。所谓的整理,就是将内存中还存活的对象向一边移动,直至这些对象相互靠拢,整齐排列,然后直接清除不属于这一部分的全部内存。标记—整理的好处是解决内存碎片的问题。以下是这个算法的演示图: