JAVA虚拟机笔记(1) 浅谈java内存与垃圾回收


这几天主要在学习java虚拟机的相关知识,在此谈谈自己的心得体会,如有不对之处,希望各位多多指出,谢谢。本次心得主要参考了《深入理解java虚拟机》第二和三章内容,也算是学习的一个总结。

1. JAVA虚拟机的内存划分

在之前java的学习过程中,我也接触过java内存分配的一些知识,看过一些博文,但是没有系统的总结和划分。这次借着学习java虚拟机的机会,正好做一次完整的总结。java程序执行的过程中,虚拟机会把内存主要划分为以下几个 部分:
1.程序计数器
2.虚拟机栈
3.本地方法栈
4.堆内存
5.方法区
其中,1,2,3为线程隔离的数据区,4,5为线程共享的数据区。那么什么是线程隔离呢,实际上就是每个线程都自己独立创建了一块数据区,独立存储相关数据,互不影响。就比如与别人合租,你的卧室就是你自己独有的,而客厅则是共享。下面,我对每个区域的功能进行简短的总结:

程序计数器: 用来存放当前线程所执行字节码的行号,此区域没有规定任何内存溢出的情况。

虚拟机栈: 描述java方法执行的内存模型。随着方法的调用和执行,此区域会创建栈帧,进行入栈和出栈操作。并且栈帧大小在 编译期就已经知道了。每个栈帧中存储了局部变量表,操作数栈,动态链接,方法出口等信息。其中,局部变量表存放了基本数据类型,对象的引用以及returnAddress类型。一般来说,当线程请求的区域过大时,会出现StackOverflowError异常,当无法扩展时,则会出现OutOfMemoryError异常。

本地方法栈: 与虚拟机栈结构一致,只是用来存放Native方法的,在目前我们使用的HotSpot虚拟机中,这两个区域合二为一。

堆内存: 该区域就是用来存放对象实例的。也是垃圾回收的主要区域。目前使用的收集器是采用分代收集算法的,因此,我们可以将该区域划分为新生代和老年代,并且新生代里面可以划分Eden,From Survivor和To Survivor区。这些概念在后面会被提及,大家先了解下即可,该区域内存不足时,会出现OutOfMemoryError异常。

方法区 该区域用来存放类信息,常量以及静态变量。该区域可以选择不实现垃圾收集。该区域收集的主要目标也仅仅只是常量池的回收以及类型的卸载。该区域一样会出现OutOfMemoryError异常。
在方法区中有一个重要的部分就是运行时常量池,这块区域主要是用来存放符号以及字面量的引用。

直接内存 该区域不是虚拟机运行时的数据区。JDK1.4中,NIO类的DirectBuffer对象存储的数据不在存储在堆内存中,而是存储在计算机的物理内存中,但是该区域会受到本机内存的限制,也会出现OutOfMemoryError异常。

2.HotSpot虚拟机的对象创建过程

目前我们学习所使用的虚拟机为HotSpot,在这里,我们以该虚拟机的对象创建过程为例子,对上面的内存区域各有一些了解。

public class Test1 {
	
	public static int a=2;   //静态变量存储在方法区中
	public static void main(String[] args) {
		String s="我爱学JAVA,哈哈哈";  // 该字符串为字面量,所以其引用放在常量池中。
		int b =3;                      //基本数据类型,3直接放在局部变量表上
		Test1 t =new Test1(); //放在堆中,本节重点以此为例,讲述下,new的对象创建过程
	}
}

在这里插入图片描述

如上图所示,当虚拟机执行main方法时,在java虚拟栈中创建一个main方法的栈帧,进行入栈操作,该栈中存放了局部变量表,分别为s,b和t,其中,b为基本数据类型,直接存储在栈中,而s对应的字符串是字面量,因此其引用存储在运行时常量池中,t为对象的引用,存储在堆内存中,因为a是静态变量,所以创建t之后,其需要到方法去中去引用2。下面重点谈谈new创建对象的过程。
当使用new方法创建对象时,虚拟机首先检查指令的参数是否在常量池中定位到一个类的符号引用,并且检测该类是否被加载,如果没有先进行加载。
之后开始进行对象的内存分配工作,这里有两种方法,一种为指针碰撞法,另一种为空闲列表法。指针碰撞要求内存是规整的,指针可以往空闲方向移动一块区域;而空闲列表则在列表中记录有哪些空闲的区域,按照列表进行空间分配,并更新列表数据。
当然划分的过程中会出现同步的问题。这时候就有两种解决方案, 一种采用CAS乐观锁来保证划分的原子性,另外一种则采用本地线程缓冲机制,即给每个线程分别预留一块TLAB内存,供该线程进行分配。
在分配完内存之后,虚拟机将内存进行初始化,对于对象进行 必要设置,这里主要是对象头的设置,其一般根据对象的状态进行复用。这里不进行详细叙述,有兴趣可以自己百度下复用表。(如果是数组,头对象中还应该有数组的长度信息)之后就可以开始执行< init >方法,按照我们的构造函数进行初始化了。
这里补充一个对象的引用方式,其主要有两种方式,一种是直接引用,另一种是采用句柄访问,因为方法区中含有对象的类型数据,如果采用直接引用,则对象的实例数据中需要包含对象数据类型的指针(这部分信息存储在对象头中),下面用2张图表示两种方式的区别。直接引用速度快,而句引用在对象被移动时不需要修改引用指针。
在这里插入图片描述

在这里插入图片描述

3.内存溢出实践

由于本书上的实践例子较多,在这里我就列举两个例子,给大家体会下如果设置一些虚拟机参数,到达我们想要看到的内存溢出问题
1.堆内存溢出


public class HeapOOM {
	static class OOMObject{
		
	}
	public static void main(String[] args) {
		List<OOMObject> list=new  ArrayList<OOMObject>();
		while(true){
			list.add(new OOMObject());
		}
		
	}
}

虚拟机参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
结果:
在这里插入图片描述
上述例子将java的对内存设置为了20M,通过运行我们可以看到,控制台上出现了OutOfMemoryError异常,并且溢出的是对内存。
2. 虚拟机栈内存溢出

public class JavaVMStackSOF {
	private int stackLength=1;
	
	public void stackLeak(){
		stackLength++;
		stackLeak();
	}

	public static void main(String[] args) {
		 JavaVMStackSOF oom =new  JavaVMStackSOF();
		 try{
			 oom.stackLeak(); 
		 }catch(Throwable e){
			 System.out.println("Stack length:"+oom.stackLength);
			 throw e;
		 }		
	}
}

虚拟机设置参数:-Xss128k.
结果:
在这里插入图片描述
通过不断运行可以看到,控制台上出现了StackOverflowError异常。

4.JAVA垃圾收集器

虚拟机对于垃圾进行回收时,首先需要进行垃圾进行判断,这里就要提到两种算法。
1 引用计数法(基本不用的方法)
简单来说,就是堆内存的一个对象,一旦被引用了就在计数器上+1,引用释放就-1,计数器为0就认为该内存是垃圾,但是当两个java对象互相引用时,此方法就无法进行标记。
2 可达性分析法
从GCRoots 出发,进行搜索,搜索不到的我们认为是垃圾。我们认为可以作为GCRoots 的对象为:虚拟机栈中的引用对象,方法区中静态属性引用的对象,方法区中常量引用的对象,本地方法栈中Native方法引用的对象。

在判断之后,我们需要对垃圾对象进行回收了,这里需要提及一下,并不是说垃圾就立即就被回收了,一般如果没有重写finalize()方法或者说虚拟机调用过该对象的finalize()方法,那么该对象会被放在一个低优先级的Finalizer线程中进行执行,在此过程中,如果对象重新与引用链上的任意一个对象发生关联,就可以不被清除,逃脱成功。但是一个逃脱成功的对象再次进入Finalizer线程中,无论如何不能再次逃脱。

下面介绍一下垃圾收集算法:
1 标记-清除法:
此方法如其名字一样,先对回收对象进行标记,然后进行清除。但是此种方法有两个缺点:1 清除效率低 2 由于只进行清除操作,那么内存空间会被分割成很多不连续的内存碎片,这样分配大对象时则需要再次垃圾收集,效率不高。
2.复制算法:
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样做的缺点是我始终只使用了一般的内存。在目前的虚拟机对此进行改进,将内存分为Eden,From Survivor和To Survivor区,其中Eden和survior的比例为8:1,每次只使用一块survior进行存活对象的存储,将Eden和另一快survior进行清除。从这个特性,我们可以知道,这种算法适合用在垃圾回收率较高的区域,也就是新生代中。与新生代对应的,老年代的内存中,基本不发生垃圾回收。当有时候存活的对象多于10%时,有时需要和老年代进行分配担保工作。
3.标记-整理算法:
此算法先进行标记,然后把没有标记的对象移到一边,剩下的进行清除。
从上面的算法特性,我们可以总结出,1,3算法适合老年代,而2算法则适合新生代。

下面简单介绍下几种主流的垃圾收集器:

用于新生代的垃圾收集器:
Serial收集器: 采用复制算法;单线程收集; 进行垃圾收集时,必须暂停所有工作线程,直到完成;即会"Stop The World",可与CMS配合使用。
ParNew收集器: 采用复制算法;多线程收集;进行垃圾收集时,必须暂停所有工作线程,直到完成;即会"Stop The World",可与CMS配合使用。
Parallel收集器: 基本与ParNew相同,但是可以进行吞吐量的控制以及最大垃圾停顿时间的控制,属于吞吐量优先收集器。
用于老年代的垃圾收集器:
CMS收集器: 采用标记清除算法,多线程收集,可以并发处理,停顿时间低的收集器。但是,对于CPU资源敏感,无法处理浮动垃圾,空间碎片过多。
最厉害的收集器:
G1收集器: 并发处理,空间整合算法,可预测停顿。
这里的空间整合算法,依然是将内存划分为新生代和老年代,但是两者之间不在物理隔离。 将内存化整为零,变为多个大小相等的独立区域,尽可能提高收集效率,但是这样存在一个问题,即一个对象分配在Region中,它并非只被本Region中的其他对象引用,而是有可能被其它区域中的对象引用。
G1收集器采用了Remember Set来解决这一问题,主要程序在对引用类型进行写操作时,会检测引用对象是否处在不同的区域中,如果是,则把引用信息记录到对象所属的区域的Remember Set表中来保证收集操作的实行。

5.内存的分配策略

java内存分配策略相对比较简单,可以用几句话进行总结,(这里的内存分配不包括栈上分配技术):

1.对象优先分配在Eden区:
当Eden区不够分配,会采用分配担保,“借”内存,并且担保的顺序为Survior区,老年代。
2.大对象直接分配在老年代:
这里可以采用-XX:PretenureSizeThreshold参数进行设置,当对象大于设置值时会直接存入老年代中。
3.长期存活的对象将进入老年代:
这里可以采用-XX:MaxTenuringThreshold参数进行设置,当对象存活次数大于设置值时会直接存入老年代中。
4.动态对象年龄判定:
当Survivor区中的同龄对象大于该区域总和的一般,则将大于等于该年龄的对象送入老年区。

以上为我对此的一些总结,路漫漫其修远兮,继续加油!!!

猜你喜欢

转载自blog.csdn.net/weixin_44183252/article/details/85247139