程序员人生 网站导航

JVM理解其实并不难!

栏目:php教程时间:2016-06-04 15:22:24

我的简书同步发布:JVM理解其实其实不难!

在浏览本文之前,先向大家强烈推荐1下周志明的《深入理解Java虚拟机》这本书。

前些天面试了阿里的实习生,问到关于Dalvik虚拟性能不能履行class文件,我当时的回答是不能,但是它履行的是class转换的dex文件。当面试官继续问,为何不能履行class文件时,我却只能回答Dalvik虚拟机内部的优化缘由,却不能正确回答具体的缘由。其实周志明的这本书就有回答:Dakvik其实不是1个Java虚拟机,它没有遵守Java虚拟机规范,不能履行Java的class文件,使用的是寄存器架构而不是JVM中常见的栈架构,但是它与Java又有着千丝万缕的关系,它履行的dex文件可以通过class文件转化而来

其实在本科期间,就有接触过《深入理解Java虚拟机》,但是1直以来都没去仔细研读,现在回头想一想实在是觉得惋惜!研1期间花了很多时间研读,现在准备找工作了,发现好多内容看了又忘。索性写1篇文章,把这本书的知识点做1个总结。固然了,如果你想看比较详细的内容,可以翻看《深入理解Java虚拟机》。

JVM内存区域

我们在编写程序时,常常会遇到OOM(out of Memory)和内存泄漏等问题。为了不出现这些问题,我们首先必须对JVM的内存划分有个具体的认识。JVM将内存主要划分为:方法区、虚拟机栈、本地方法栈、堆、程序计数器。JVM运行时数据区以下:
JVM运行时数据区

程序计数器

程序计数器是线程私有的区域,很好理解嘛~,每一个线程固然得有个计数器记录当前履行到那个指令。占用的内存空间小,可以把它看成是当前线程所履行的字节码的行号唆使器。如果线程在履行Java方法,这个计数器记录的是正在履行的虚拟机字节码指令地址;如果履行的是Native方法,这个计数器的值为空(Undefined)。此内存区域是唯逐一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域

Java虚拟机栈

与程序计数器1样,Java虚拟机栈也是线程私有的。其生命周期与线程相同。如何理解虚拟机栈呢?本质上来说,就是个栈。里面寄存的元素叫栈帧,栈帧好像很复杂的模样,其实它很简单!它里面寄存的是1个函数的上下文,具体寄存的是履行的函数的1些数据。履行的函数需要的数据不过就是局部变量表(保存函数内部的变量)、操作数栈(履行引擎计算时需要),方法出口等等。

履行引擎每调用1个函数时,就为这个函数创建1个栈帧,并加入虚拟机栈。换个角度理解,每一个函数从调用到履行结束,实际上是对应1个栈帧的入栈和出栈。

注意这个区域可能出现的两种异常:1种是StackOverflowError,当前线程要求的栈深度大于虚拟机所允许的深度时,会抛出这个异常。制造这类异常很简单:将1个函数反复递归自己,终究会出现栈溢出毛病(StackOverflowError)。另外一种异常是OutOfMemoryError异常,当虚拟机栈可以动态扩大时(当前大部份虚拟机都可以),如果没法申请足够多的内存就会抛出OutOfMemoryError,如何制作虚拟机栈OOM呢,参考1下代码:

public void stackLeakByThread(){ while(true){ new Thread(){ public void run(){ while(true){ } } }.start() } }

这段代码有风险,可能会致使操作系统假死,请谨慎使用~~~

本地方法栈

本地方法栈与虚拟机栈所发挥的作用很类似,他们的区分在于虚拟机栈为履行Java代码方法服务,而本地方法栈是为Native方法服务。与虚拟机栈1样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

Java堆

Java堆可以说是虚拟机中最大1块内存了。它是所有线程所同享的内存区域,几近所有的实例对象都是在这块区域中寄存。固然,睡着JIT编译器的发展,所有对象在堆上分配渐渐变得不那末“绝对”了。

Java堆是垃圾搜集器管理的主要区域。由于现在的搜集器基本上采取的都是分代搜集算法,所有Java堆可以细分为:新生代和老年代。在细致分就是把新生代分为:Eden空间、From Survivor空间、To Survivor空间。当堆没法再扩大时,会抛出OutOfMemoryError异常。

方法区

方法区寄存的是类信息、常量、静态变量等。方法区是各个线程同享区域,很容易理解,我们在写Java代码时,每一个线程度可以访问同1个类的静态变量对象。由于使用反射机制的缘由,虚拟机很难推测那个类信息不再使用,因此这块区域的回收很难。另外,对这块区域主要是针对常量池回收,值得注意的是JDK1.7已把常量池转移到堆里面了。一样,当方法区没法满足内存分配需求时,会抛出OutOfMemoryError。
制造方法区内存溢出,注意,必须在JDK1.6及之前版本才会致使方法区溢出,缘由后面解释,履行之前,可以把虚拟机的参数-XXpermSize和-XX:MaxPermSize限制方法区大小。

List<String> list =new ArrayList<String>(); int i =0; while(true){ list.add(String.valueOf(i).intern()); }

运行后会抛出java.lang.OutOfMemoryError:PermGen space异常。
解释1下,Stringintern()函数作用是如果当前的字符串在常量池中不存在,则放入到常量池中。上面的代码不断将字符串添加到常量池,终究肯定会致使内存不足,抛出方法区的OOM。

下面解释1下,为何必须将上面的代码在JDK1.6之前运行。我们前面提到,JDK1.7后,把常量池放入到堆空间中,这致使intern()函数的功能不同,具体怎样个不同法,且看看下面代码:

String str1 =new StringBuilder("hua").append("chao").toString(); System.out.println(str1.intern()==str1); String str2=new StringBuilder("ja").append("va").toString(); System.out.println(str2.intern()==str2);

这段代码在JDK1.6和JDK1.7运行的结果不同。JDK1.6结果是:false,false ,JDK1.7结果是true, false。缘由是:JDK1.6中,intern()方法会吧首次遇到的字符串实例复制到常量池中,返回的也是常量池中的字符串的援用,而StringBuilder创建的字符串实例是在堆上面,所以必定不是同1个援用,返回false。在JDK1.7中,intern不再复制实例,常量池中只保存首次出现的实例的援用,因此intern()返回的援用和由StringBuilder创建的字符串实例是同1个。为何对str2比较返回的是false呢?这是由于,JVM中内部在加载类的时候,就已有"java"这个字符串,不符合“首次出现”的原则,因此返回false

垃圾回收(GC)

JVM的垃圾回收机制中,判断1个对象是不是死亡,其实不是根据是不是还有对象对其有援用,而是通过可达性分析。对象之间的援用可以抽象成树形结构,通过树根(GC Roots)作为出发点,从这些树根往下搜索,搜索走过的链称为援用链,当1个对象到GC Roots没有任何援用链相连时,则证明这个对象是不可用的,该对象会被判定为可回收的对象。

那末那些对象可作为GC Roots呢?主要有以下几种:

1.虚拟机栈(栈帧中的本地变量表)中援用的对象。
2.方法区中类静态属性援用的对象。
3.方法区中常量援用的对象
4.本地方法栈中JNI(即1般说的Native方法)援用的对象。

另外,Java还提供了软援用和弱援用,这两个援用是可以随时被虚拟机回收的对象,我们将1些比较占内存但是又可能后面用的对象,比如Bitmap对象,可以声明为软援用货弱援用。但是注意1点,每次使用这个对象时候,需要显示判断1下是不是为null,以避免出错。

3种常见的垃圾搜集算法

1.标记-清除算法

首先,通过可达性分析将可回收的对象进行标记,标记后再统1回收所有被标记的对象,标记进程其实就是可达性分析的进程。这类方法有2个不足点:效力问题,标记和清除两个进程的效力都不高;另外一个是空间问题,标记清除以后会产生大量的不连续的内存碎片。

2.复制算法

为了解决效力问题,复制算法是将内存分为大小相同的两块,每次只使用其中1块。当这块内存用完了,就将还存活的对象复制到另外一块内存上面。然后再把已使用过的内存1次清算掉。这使得每次只对半个区域进行垃圾回收,内存分配时也不用斟酌内存碎片情况。

但是,这代价实在是让人没法接受,需要牺牲1般的内存空间。研究发现,大部份对象都是“朝生夕死”,所以不需要安装1:1比例划份内存空间,而是将内存分为1块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和1块Survivor空间,默许比例为Eden:Survivor=8:1.新生代区域就是这么划分,每次实例在Eden和1块Survivor中分配,回收时,将存活的对象复制到剩下的另外一块Survivor。这样只有10%的内存会被浪费,但是带来的效力却很高。当剩下的Survivor内存不足时,可以去老年代内存进行分配担保。如何理解分配担保呢,其实就是,内存不足时,去老年代内存空间分配,然后等新生代内存缓过来了以后,把内存归还给老年代,保持新生代中的Eden:Survivor=8:1.另外,两个Survivor分别有自己的名称:From Survivor、To Survivor。2者身份常常调换,即有时这块内存与Eden1起参与分配,有时是另外一块。由于他们之间常常相互复制。

3.标记-整理算法

标记整理算法很简单,就是先标记需要回收的对象,然后把所有存活的对象移动到内存的1端。这样的好处是避免了内存碎片。

类加载机制

类从被加载到虚拟机内存开始,到卸载出内存为止,全部生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。

其中加载、验证、准备、初始化、和卸载这5个阶段的顺序是肯定的。而解析阶段不1定:它在某些情况下可以在初始化阶段以后再开始,这是为了支持Java的运行时绑定。

关于初始化:JVM规范明确规定,有且只有5中情况必须履行对类的初始化(加载、验证、准备自然再此之前要产生):
1.遇到new、getstatic、putstatic、invokestatic,如果类没有初始化,则必须初始化,这几条指令分别是指:new新对象、读取静态变量、设置静态变量,调用静态函数。
2.使用java.lang.reflect包的方法对类进行反射调用时,如果类没初始化,则需要初始化
3.当初始化1个类时,如果发现父类没有初始化,则需要先触发父类初始化。
4.当虚拟机启动时,用户需要制定1个履行的主类(包括main函数的类),虚拟机会先初始化这个类。
5.但是用JDK1.7启的动态语言支持时,如果1个MethodHandle实例最后解析的结果是REF_getStaticREF_putStaticRef_invokeStatic的方法句柄时,并且这个方法句柄所对应的类没有进行初始化,则要先触发其初始化。

另外要注意的是:通过子类来援用父类的静态字段,不会致使子类初始化

public class SuperClass{ public static int value=123; static{ System.out.printLn("SuperClass init!"); } } public class SubClass extends SuperClass{ static{ System.out.println("SubClass init!"); } } public class Test{ public static void main(String[] args){ System.out.println(SubClass.value); } }

最后只会打印:SuperClass init!
对应静态变量,只有直接定义这个字段的类才会被初始化,因此通过子类类援用父类中定义的静态变量只会触发父类初始化而不会触发子类初始化。

通过数组定义来援用类,不会触发此类的初始化

public class Test{ public static void main(String[] args){ SuperClass[] sca=new SuperClass[10]; } }

常量会在编译阶段存入调用者的常量池,本质上并没有直接援用到定义常量的类,因此不会触发定义常量的类初始化,示例代码以下:

public class ConstClass{ public static final String HELLO_WORLD="hello world"; static { System.out.println("ConstClass init!"); } } public class Test{ public static void main(String[] args){ System.out.print(ConstClass.HELLO_WORLD); } }

上面代码不会出现ConstClass init!

加载

加载进程主要做以下3件事
1.通过1个类的全限定名称来获得此类的2进制流
2.强这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3.在内存中生成1个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。

验证

这个阶段主要是为了确保Class文件字节流中包括信息符合当前虚拟机的要求,并且不会出现危害虚拟机本身的安全。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都在方法区中分配。首先,这个时候分配内存仅仅包括类变量(被static修饰的变量),而不包括实例变量。实例变量会在对象实例化时随着对象1起分配在java堆中。其次这里所说的初始值“通常情况下”是数据类型的零值,假定1个类变量定义为

public static int value=123;

那变量value在准备阶段后的初始值是0,而不是123,由于还没有履行任何Java方法,而把value赋值为123是在程序编译后,寄存在类构造函数<clinit>()方法中。

解析

解析阶段是把虚拟机中常量池的符号援用替换为直接援用的进程。

初始化

类初始化时类加载的最后1步,前面类加载进程中,除加载阶段用户可以通过自定义类加载器参与之外,其余动作都是虚拟机主导和控制。到了初始化阶段,才是真正履行类中定义Java程序代码。

准备阶段中,变量已赋过1次系统要求的初始值,而在初始化阶段,根据程序员通进程序制定的主观计划初始化类变量。初始化进程实际上是履行类构造器<clinit>()方法的进程。

<clinit>()方法是由编译器自动搜集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。搜集的顺序是依照语句在源文件中出现的顺序。静态语句块中只能访问定义在静态语句块之前的变量,定义在它以后的变量可以赋值,但不能访问。以下所示:

public class Test{ static{ i=0;//給变量赋值,可以通过编译 System.out.print(i);//这句编译器会提示:“非法向前援用” } static int i=1; }

<clinit>()方法与类构造函数(或说实例构造器<init>())不同,他不需要显式地调用父类构造器,虚拟机会保证子类的<clinit>()方法履行之前,父类的<clinit>()已履行终了。

类加载器

关于自定义类加载器,和双亲委派模型,这里不再提,写了几个小时了,该洗洗睡了~

------分隔线----------------------------
------分隔线----------------------------

最新技术推荐