【朝花夕拾】Android性能篇之(二)Java内存分配
前言
转载请声明,转自【https://www.cnblogs.com/andy-songwei/p/9291186.html】,谢谢!
在内存方面,相比于C/C++程序员,咱们java系程序员算是比较幸运的,因为对于内存的分配和回收,都交给了JVM来处理了,而不需要手动在代码中去完成。有了虚拟机内存管理机制,也就不那么容易出现内存泄漏和内存溢出的问题了。不那么容易出现,并不代表就不会出现。正是由于程序员将内存的控制大权交了出去,那么一旦出现了内存泄漏和内存溢出的问题,如果虚拟机如何分配内存的工作机制不了解,那这就成了一个难以处理的问题了。所以说,放权可以,但不能完全失去控制,否则,就有被架空的危险,出了问题,你只能干捉急。
本文的主要内容如下:
一、内存的家庭住址
我们这么关心的内存,到底是何方神圣呢?看图比看文字舒服,咱们先上图:
似曾相识吧!这个就是第一节中JVM执行java程序的流程。ClassLoader加载完毕.class文件后,交由执行引擎执行。整个程序执行过程中,JVM会用一段空间来存储执行期间需要用到的数据和相关信息,这段空间一般被称作Runtime Data Area (运行时数据区),这就是咱们常说的JVM内存,我们常说到的内存管理就是针对这段空间进行管理。这样,我们就找到内存的家庭住址了。
二、内存大家庭中都有哪些成员呢?
咱们仍然先上图:
Java虚拟机在执行Java程序的过程中会把它所管理的内存(运行时数据区)划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(Java SE 7版)》的规定,Java虚拟机所管理的内存包含了上图中的5个区域:程序计数器,虚拟机栈,本地方法栈,GC堆,方法区。
三、内存的家庭成员分别都是干嘛的呢?
这一部分比较理论,文字描述比较多,但是如果有一定的基础而且认真读的话,其实很容易懂的,同时要想更好地理解内存这方面的知识,也需要耐着性子好好看。
1、程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,也有的称为PC寄存器。
学过汇编语言或者计算机机构与组成原理的童鞋,应该对着个概念不陌生,在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址,当CPU需要执行指令的时候,就从中取出这条地址,并根据这条地址获取到指令。获取到指令后,程序计数器会自动+1或者根据转移指针得到下一条指令的地址,如此循环,直到执行完所有的指令。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
虽然JVM中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器,但是JVM中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来指示执行哪条指令的。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值为空(undefined)。这块内存中存储的数据所占空间的大小不会随程序的执行而发生改变,所以,此内存区域不会发生内存溢出(OutOfMemory)问题,该内存区域也是唯一一个在JVM规范中没有规定任何OutOfMemoryError情况的区域。
2、Java虚拟机栈
Java虚拟机栈(Java Vitual Machine Stack)简称为Java栈,也就是我们常常说的栈内存。它是Java方法执行的内存模型。
如上图所示,Java栈中存放的是一个个的栈帧,每个栈帧对应的是一个被调用的方法。每一个栈帧中包括了如下部分:局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。因此可以知道,线程当前执行的方法所对应的栈必定位于Java虚拟机栈的顶部。在Java虚拟机规范中,对Java栈区域规定了两种异常状况:1)如果线程请求的栈深度大于虚拟机所允许的深度,将抛出栈内存溢出(StackOverflowError)异常;2) 如果虚拟机栈可以动态扩展,而且扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。到这里,我们就容易理解,在使用递归方法的时候,如果这个方法的层次太深,就会导致Java栈中的栈帧过多,从而导致栈内存溢出。这部分空间的分配和释放都是由系统自动实施的,而不需要程序员去管理了。
经常有人把Java的内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,实际划分远比这复杂。之所以这种分法能够流行,说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域主要是这两块。下面咱们对栈帧再做细致的描述。
(1)局部变量表。顾名思义,它是一组变量值存储空间,用于存放对应方法的形参和方法内部定义的非static局部变量。其中存放的数据的类型有如下几种:a)基本数据类型。boolean,char,byte,short,int,long,float,double,java中定义的8种基本数据类型。b)对象引用(reference)。不是对象本身,而是指向对象实例的一个引用,这个就是Java中的指针,他的值为一个地址,在堆中该实例的首地址。例如,Date date = new Date(...);new Date(...)表示在堆内存中开辟了一个空间来存储该实例对象,而date就是对象的引用,局部变量表中存储的就是指向堆中该对象的首地址。c) retunAddress类型。它指向了一条字节码指令的地址。这一点没有查得很明白,笔者估计应该是方法执行完毕后,返回给程序计数器的当前指令的地址吧。局部变量表所需的内存空间在编译期间完成分配,即在Java程序被编译成.class文件时,就确定了所需要分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变其大小。
(2)操作数栈。其又常被称为操作栈,它的最大深度也是在编译的时候就确定了。当一个方法开始执行时,它的操作栈是空的,在方法执行过程中,会有各种字节码指令(比如:加操作、赋值运算等)向操作栈中写入内容,也就是入栈,计算完毕后提取内容,即出栈操作。学过数据结构的童鞋,一定对表达式求职问题不会陌生,栈最典型的一个应用就是用来对表达式求值。一个线程执行方法的过程中,实际上就是不断执行语句的过程,归根到底就是进行计算的过程,可以说,程序中的所有计算过程都是在借助于操作数栈来完成的。Java虚拟机的解释执行引擎也被称为“基于栈的执行引擎”,这里“栈”就是操作数栈。因此我们也称Java虚拟机是基于栈的,这点不同于Android虚拟机,Android虚拟机是基于寄存器的。基于栈的指令集最主要的优点是可移植性强,主要缺点是执行速度相对较慢;而由于寄存器由硬件直接提供,所以基于寄存器指令集最主要的优点是执行速度快,主要缺点是可移植性差。
(3)指向当前方法所属的类的运行时常量池的引用。很多地方也称这个部分为动态连接。每个栈帧都包含一个指向运行时常量池(在方法区中详细介绍)的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或一第一次使用的时候转化为直接引用(如final,static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。简单点说,就是因为在方法执行的过程中有可能需要用到类中的常量,所以需要有一个引用指向运行时常量。
(4)方法返回地址。当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。方法被执行后,有两种方式退出该方法:一种是执行引擎遇到任意一个方法返回的字节码指令,也就是遇到了return,或者void函数执行完毕;另外一种是遇到了异常,并且该异常没有在方法体内得到处理,即没有用try-catch进行捕获。无论是哪种退出方式,在退出后都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值。而方法异常退出时,返回地址是要通过异常处理来确定的,栈帧一般不会保存这部分信息。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法方法调用指令后面的一条指令。
每个线程拥有自己的Java栈,调用自己的方法,互不干扰,属于“私有内存”。
3、本地方法栈(Native Method Stack)
本地方法栈与Java虚拟机栈的作用和原理非常相似,区别在与前者为执行Nativit方法服务的,而后者是为执行Java方法服务的。在JVM规范中对本地方法栈中方法使用的语言,使用方式和数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。在HotSpot虚拟机中,直接把本地方法栈和Java栈合二而一了,而我们平时Java开发中,最常用到的就是HotSpot虚拟机。与Java虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
4、GC堆
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配。堆是被所有线程共享的,在JVM中只有一个堆。这一点在Java虚拟机规范中的描述为:所有的对象实例以及数组都要在对上分配,但是随着JIT编译器(即时编译器:是一种提高程序运行效率的方法,通常由两种运行方式,静态编译与动态编译。静态编译是指执行前全部翻译为机器码,动态编译时指,一句一句地边翻译边运行)的发展与逃逸技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在对上也渐渐变得不那么绝对了。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”(Garbage Collected Heap)。如果还细分,有新生代和老年代等的划分,此处不详细展开,有兴趣和需要深入的可以自行研究。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流还是可以扩展的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且也无法再扩展时,将会抛出OutOfMemoryError异常。
5、方法区
方法区(Method Area)在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域,一般用来存储不容易改变的数据,所以一般也被称为“永久代”。在方法区中,存储了每个类的信息(包括类名,方法信息,字段信息)、静态变量、常量以及编译器编译后的代码等。在Class文件中除了类的字段、方法、接口等描述信息外,还有运行时常量池,用来储存编译期间生成的字面量和符号引用。在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或者接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法(这一段是不是看得比较蒙?这里先整体提一下,后面还会对该段内容做详细整理,毕竟这一段全是知识点)。
JVM垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。不过,从JDK7之后,HotSpot虚拟机便将运行时常量池从永久代中移除了。
Java虚拟机规范把方法区描述为Java堆的一个逻辑部分,而且它和Java Heap一样不需要连续的内存,可以选择固定大小或可扩展,可以允许该区域选择不实现垃圾回收。相对而言,垃圾收集行为在这个区域出现比较少,该区域的内存回收目标主要是针对废弃常量和无用类的回收。为了区别于Java-Heap,方法区也被称为Non-Heap区。根据规范,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
四、方法区到底存储了哪些信息?
上一节中的第5点中,概括性地讲到了方法区存储的信息,说得比较笼统,那样是远远不够的,笔者仍然需要再更细致地探究一下,才能更深入地理解。
1、类信息
(1)类型的全限定名:即类的完整有效名。在Java源代码中,完整有效名由类的所属包名称加一个".",再加上类名组成。如,Object类所属的包为java.lang,那它的完整名称为java.lang.Object,但是在类的文件里,所有的“.”都被斜杠 “/” 代替,就成为java/lang/Object。完整有效名在方法区中的表示根据不同的实现而不同。
(2)超类的全限定名:即直接父类的完整有效名。
(3)直接超接口的全限定名:即实现的接口的完整有效名。
(4)类型标志:即该类是普通类类型还是接口类型。
(5)类的访问描述符:如publlic,private,default,protected,abstract,final,static等
2、类的常量池
JVM为每个已加载的类型都维护一个常量池,是这个类用到的常量的一个有序集合,包括实际的常量(String,Integer,Floating Point常量)和对类、域(属性)和方法的符号引用(符号引用在后面会讲到)。池中的数据项像数组项一样,是通过索引访问的。因为常量池存储了一个类型所使用到的所有类、域和方法的符号引用,所以它在Java程序的动态链接中起了核心的作用。(这一部分下一节会和运行时常量一起详细讲到)
3、字段信息(该类声明的所有字段,也称Field,属性,域)
(1)字段修饰符:如public、protected,private,default
(2)字段的类型:比如int,float等8种基本类型和引用类型
(3)字段的名称:这个好理解
4、方法信息(方法信息中包含类中的所以方法,每个方法又包含了如下信息)
(1)方法修饰符:public、protected,private,default,static,final,synchronized,native,abstract等
(2)方法返回类型:比如public String getName(String id)中的String即为返回类型,包括void
(3)方法名:如上述中的getName
(4)方法参数个数、类型、顺序等
(5)方法字节码
(6)操作数栈和方法栈帧的局部变量区的大小
5、类变量
即静态成员变量,被static修饰的变量,为该类所有对象共享的变量,即使没有任何实例对象时,也可以访问的类变量,它们与类进行绑定,成为类数据在逻辑上的一部分。这个和第3)点区分开来,第3)点为实例变量。在JVM使用一个类之前,它必须在方法区中为每一个non-final的类变量提前分配空间。对于被final修饰的类变量(常量),会在常量池中有一个拷贝,而non-final 类变量则被存储在声明它的类信息中,这里要注意final和no-final修饰的区别。
注意:Java类中的成员变量有静态和非静态之分。静态成员变量在方法区,为共享数据;非静态成员变量,在new 一个对象的时候被分配在堆内存中。局部变量则是方法内定义的变量,前面已经讲过,它会被分配在Java虚拟机栈内存中。虚拟机栈内存中会为当前方法非配一个栈帧,栈帧中有一个局部变量表,该表存储了该变量的值(基础类型)或对象在堆中的地址(引用类型)。
举个栗子:
- int i; 在类中定义(不是在方法中定义),为第3)点中讲到的,为实例变量,需要类的实例才能调用,保存在堆中对应的对象实例中。
- static int i ;non-final修饰的类变量,保存方法区中的类信息中。
- final static int I=0; final修饰的类变量,此时I就成为了一个常量了,必须赋值,否则报错。它会在常量池中有一个拷贝。
6、指向类加载器的引用
每一个被JVM加载的类,都保存这个类加载器的引用,类加载器动态链接时会用到。当解析一个类到另一个类的引用时,JVM需要保证这两个类的加载器是相同的,这对JVM区分名字空间的方式是至关重要的。
7、指向Class实例的引用
类加载的过程中,虚拟机会为每个加载的类(包括类和接口)都创建一个java.lang.Class的实例,JVM必须以某种方式把这个Class实例和存储在方法区中的类数据联系起来。在Class类中有个静态方法可以得带这个实例的引用,public static Class forName(String className),通过Class.forName(String className)(反射)来查找获得该实例的引用,然后创建该类的对象(这里和直接new一个对象区分开来)。例如,通过调用 Class.forName(“java.lang.Object”),可以得到与java.lang.Object对应的类对象(这里用到了工厂模式),甚至可以通过这个函数得到任何包中任何已经加载的类引用,只要这个类能够被加载到当前的名字空间。如果不能把类加载到当前名字空间,forName就会抛出ClassNotFoundException。
Class类还提供了如下方法,获取到类的对象后,可以用这些方法得到对应的类存储在方法区中的类信息:
- public String getName(); //获取类名
- public Class getSuperClass(); //获取父类对象
- public boolean isInterface(); // 判断是否为接口
- public Class[] getInterfaces(); //返回一组接口对象,对应该类实现的接口对象。
- public ClassLoader getClassLoader(); //返回类加载器的引用。
8、方法表
为了提高访问效率,JVM可能会对每个装载的非抽象类和非接口,都创建一个数组,数组的每个元素都是实例可能调用的方法的直接引用(注意,这里说的是引用,不是方法本身,方法本身是在Java虚拟机栈的栈帧中),包括父类中继承过来的方法。JVM可以通过方法表快速激活实例方法。
9、运行时常量
JDK7后已经移除了方法区。结合第2点类的常量池,后面会有个小节再继续扩展分析。
10、即时编译(JIT)后的代码
Java的字节码文件.class文件,被JVM加载后,会一句一句翻译程机器码执行。这个区域就存储了这些机器码。(这个是笔者自己的理解,没有查到权威的结论)
五、常量池
在上一节中,我们提到了“类的常量池”和“运行时常量池”,这里我们接着来讲。
常量池分为静态常量池和运行时常量池,它们的区别在于动态性。
1、静态常量池
静态常量就是我上面提到的“类常量池”,即*.class文件中的常量池。当java文件被编译为.class文件的时候,会专门有一部分区域用于保存类中的常量,这个区域就是类常量池。.class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,他们占据了class文件的绝大部分空间。总体来说,它主要存储了两大类常量:字面量和符号引用。在这里,我们解释几个名词:
- 常量:有两种情况,第一种是一个值,如1024(整型常量)、‘a’'b''c'(字符常量)、“abc”(字符串常量)、true/false(boolean型常量)等。第二种就是被final修饰的变量,因为它的值不能再改变,也被称作常量,比如final int I = 0; 这里,I 就成为了一个常量。
- 字面量:相当于Java语言层面常量的概念。比如 String s = “abc”,这里"abc"就是一个字面量。
- 符号引用:属于编译原理方面的概念,包含了如下三种类型的的常量:
I)类和接口的全限定名:即前面第4节第1)点类信息中提到过的,比如Object类的全限定名就是java.lang.Object
II)字段名称和描述符:即前面第4节第3)点中对应的名称和修饰符。
III)方法名称和描述符:即前面第4节第4)点中对应的名称和修饰符。
2、运行时常量
上述中的静态常量池(类常量池),是在编译的时候,存在于.class文件中的,而JVM在完成.class文件的装载后,静态常量池就被载入到内存中用于程序的运行,此时,静态常量池摇身一变,成为了运行时常量池。JDK7之前的版本中,运行时常量是方法区中的一部分,可能由于方法区的空间有限,JDK7及以后的版本就把它移除了方法区,这一点在前面也多次提到过。有些资料说是移到了Java堆中,没有看到权威的资料,笔者也不敢去确定。
一点疑惑:从上面的描述来看,类/接口、方法、字段的相关信息,在上诉第4节中方法区中的类信息、字段信息、方法信息存储了一份,在类常量池中又存储一次,这样是不是冗余了?方法区是内存中的一部分,在运行期出现,而类常量池是.class文件中的一部分,在运行前就出现了,为什么方法区中会存在类变量? 是笔者参考的资料中描述有误?还是笔者理解有误?这里如果有幸被读者读到,可以自己研究一下,顺便告知于我,3Q!
3、常量池的好处
常量池是为了避免频繁地创建和销毁对象而影响到系统性能,而实现的对对象的共享。例如,字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中,这样做有两个好处:I)节省内存空间:常量池中所有相同的字符串常量合并,只占用一个空间。II)节省运行时间:比较字符时,==比equals()快。对于两个引用变量,只用==判断引用是否相等,也就可以判断实际值是否相等。
六、总结
本章中理论性的东西太多了,下图对这一章节的内容做个简单的梳理和归纳。
推荐阅读及参考资料
《深入理解Java虚拟机——JVM高级特性与最佳实践》
https://www.cnblogs.com/dolphin0520/p/3613043.html
https://www.cnblogs.com/syp172654682/p/8082625.html
https://blog.csdn.net/huangfan322/article/details/53220169
https://blog.csdn.net/zzhangxiaoyun/article/details/7518917
https://blog.csdn.net/gcw1024/article/details/51026840
https://blog.csdn.net/wangtaomtk/article/details/52267548
https://blog.csdn.net/luanlouis/article/details/40301985