JVM内存管理
与其他高级语言(例如C和C++)不同,在Java中我们基本上不会显式地调用分配内存的函数,我们甚至都不用关心哪些程序指令需要分配内存,需要分配多少的内存。因为在JVM当中,内存的分配和回收都已经由JVM自动完成了,很少会遇到像C++程序中OutOfMemoryError这样的内存泄露问题。
虽然Java语言的这些特点在很大程度上减少了开发人员的麻烦,但是我们最好还是了解一下Java是如何管理内存的,当我们真正遇到内存泄漏问题时,能够根据报错信息迅速找到内存泄漏的代码并成功dubug!
本章将会从以下几个方面介绍JVM的内存管理机制。
从操作系统层面介绍物理内存的分配和Java运行的内存分配之间的关系。
Java如何使用从物理内存申请下来的内存,以及如何来划分他们。
如何分配和回收内存
-
JVM内存管理
- 物理内存和虚拟内存
- 用户空间和内核空间
- JVM内存结构
- PC寄存器
- Java栈
- 堆
- 方法区
- 本地方法栈
- JVM内存分配区域
- 通常的内存分配策略
- Java中的内存分配详解(从分配区域角度)
- Java中的内存分配详解(从硬件角度)
- JVM内存回收策略
- 静态内存分配和回收
- 动态内存分配和回收
- 如何检测垃圾
- 引用计数法
- 可达性分析法
- 垃圾收集算法
物理内存和虚拟内存
所谓物理内存就是我们通常所说的RAM(随机存储器,内存条)。在计算机中,还有一个存储单元叫寄存器(在CPU中),它用于存储计算单元执行指令的中间结果。寄存器的大小决定了一次计算可使用的最大数值。通常操作系统管理内存的申请空间是按照进程来管理的,即每个进程拥有一段独立的物理空间地址,每个进程之间不会相互重合,操作系统也会保证每个进程只能访问自己的内存空间。
上面所说的进程的内存空间的独立主要是指逻辑上独立,这个独立是由操作系统来保证的。但是真正的物理空间是不是只能由一个进程来使用就不一定了。因为随着程序越来越大,物理内存无法满足程序的需求,在这种情况下就有了虚拟内存的出现。
虚拟内存的出现使得多个进程在同时运行时可以共享物理内存,这里的共享只是空间上的共享,在逻辑上他们仍是不能相互访问的。虚拟地址不但可以让进程共享物理内存,提高内存的利用率,同时还能够扩展内存的地址空间。如一段虚拟地址可能被映射到一段物理内存、文件或者其他可以寻址的存储上。一个进程在不活动的情况下,操作系统将这个线程的数据从物理内存移到磁盘文件中,而真正高效的物理内存留给正在活动的程序使用。当唤醒了一个很长时间没有使用到的程序时,操作系统又会把磁盘上的数据重新交互到物理内存中,但是我们要避免这种情况经常出现,因为数据的频繁交互会影响计算机的性能。
用户空间和内核空间
一个计算机通常具有一定大小的内存空间(平常所说的内存条),比如4GB,8GB,16GB等,但是程序并不能完全使用这些地址空间。因为这些地址空间被分为用户空间和内核空间,程序只能使用用户空间。
内核空间是指操作系统运行时所使用的用于程序调度,虚拟内存的使用和连接硬件资源的内存空间。为何需要划分内核空间和用户空间呢?也是出于安全的考虑,类似于上文所说的每个线程都独立使用属于自己的内存,互不干扰一样,用户程序也不能访问操作系统本身所使用的内存空间。
但如果用户程序也有访问硬件资源的需求时怎么办呢(例如网络连接)?可以通过调用操作系统所提供的接口来实现,这个调用操作系统接口的过程也就是系统调用。每一次系统调用都会存在两个内存空间的切换,例如网络连接就是一次系统调用,通过网络传输的数据先是从内核空间接收到远程主机的数据,然后再从内核空间复制到用户空间,供用户程序使用。然而,这种数据在内核空间和用户空间之间的转化很费时,虽然保证了程序运行的安全性和稳定性,但是也牺牲了一部分效率。现在已经出现了很多其他技术能够减少这种从内核空间到用户空间的数据复制的方式,如Linux系统提供的sendfile文件传输方式。
内核空间和用户空间大小如何分配也是一个问题,要根据计算机的工作重心分配不同大小的内核空间和用户空间。
用户态和内核态(扩展)
由于需要限制不同的程序之间的访问能力,防止他们获取别的程序的内存数据,或者获取外围设备的数据,CPU划分出两个权限等级----用户态和内核态。
用户态:CPU只能受限的访问内存,而且不允许访问外围设备,占用CPU的能力被剥夺,CPU资源可以被其他程序获取。
内核态:CPU可以访问内存所有的数据,包括外围设备例如硬盘和网卡等,CPU也可将自己从一个程序切换到另一个程序。
JVM内存结构
JVM是按照运行时数据的存储结构来划分内存结构的,JVM在运行Java程序时,将他们划分成几种不同格式的数据,分别存储在不同的区域,这些数据统一称为运行时数据(Runtime Data)。在JVM中,将Java运行时数据划分为以下六种:
- PC寄存器数据
- Java栈
- 堆
- 方法区
- 本地方法区
- 运行时常量池
PC寄存器
PC寄存器严格来说是一种数据结构,(PC寄存器在CPU当中,线程是CPU分配的基本单位,每个线程都拥有自己的PC寄存器)它用于保存当前正在执行的程序的内存地址。由于Java程序是支持多线程执行的,所以不可能保证每个线程都按照线性执行下去,可能线程1执行到一半cpu资源被线程2夺去,线程1发生中断。被中断的线程当前执行到哪条内存地址必然要保存下来,以便它被恢复执行时可以从中断处继续执行,这个用于保存线程当前正在执行的内存地址的数据结构就是PC寄存器,它就像一个记录员一样记录下哪个线程执行到哪条指令了。
Java栈
Java栈总是与Java线程关联在一起,每创建了一个线程时,JVM就会为这个线程创建一个对应的Java栈。这个Java栈中又会含有多个栈帧(Frames),每个栈帧都会对应一个方法,每个栈帧会含有一些内部变量(方法内部定义的变量)、操作栈和方法返回值等信息。
每当一个方法执行完成时,这个栈帧就会弹出栈帧的元素作为这个方法的返回值,并清除这个栈帧,Java栈的栈顶的栈帧就是这个当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向这个栈帧的内存地址。只有这个活动的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另外一个方法时,与之对应的一个新的栈帧又被创建,这个新创建的栈帧又被放在Java栈的顶部,变为当前的活动栈帧。同样现在只有这个栈帧的本地变量能够被操作栈使用,当这个栈帧中所有的指令执行完时这个栈帧移出Java栈,刚才的那个栈帧又变为活动栈帧,前面的栈帧的返回值又变为这个栈帧的操作栈中的一个操作数。如果前面的栈帧没有返回值,那么当前栈帧的操作栈的操作数没有变化。
堆
堆是存储Java对象的地方,它是JVM管理Java对象的核心区域,所有new出来的对象都存储在这里。堆也是我们的应用程序与内存关系最密切的存储区域。
每一个存储在堆中的Java对象都会是这个对象的类的一个副本,它会复制包括继承自它父类的所有静态属性。由于堆是被所有Java线程所共享的,所以对它的访问要注意同步问题,方法和对应的属性都要保持一致性。Java堆可以处于物理上的不连续的内存空间之中,只要逻辑上是连续的。
方法区
JVM方法区是用来存储类结构信息的地方。当通过类加载器将一个class文件解析成JVM能识别的几个部分时,这些不同的部分会被存储在不同的数据结构当中,其中的常量池、域、方法数据、方法体、构造函数,包括类中的专有方法、接口初始化等都存储在这个区域。
方法区也属于堆的一部分,也就是我们通常说的Java堆中的永久区,这个区域可以被所有的线程共享,并且它的大小可以通过参数来设置。
对于方法区来说,它所存储区域的大小一般在程序启动后的一段时间就是固定的了,JVM运行一段时间后,需要加载的类通常都已经加载到JVM当中了。但是有一种情况需要注意,那就是项目中存在动态编译的情况(Java语言是编译完再统一执行的,那么如果一个java程序在执行时调用了另外一个程序或者class文件,那么就要在执行的过程中编译这个新的被调用的文件,这就叫做动态编译),那么此时需要观察方法区的大小能否满足类存储。
本地方法栈
本地方法栈是为JVM运行Native方法准备的空间,它和前面介绍的Java栈的作用类似,只不过栈所存储的是JVM所调用的Native方法(JVM会使得Java程序运行时调用本地的方法,这就是native方法)。由于Native方法很多都是由C语言实现的,所以它通常又叫C栈。除了Native方法外,在JVM利用JIT技术时会将一些JAVA方法重新编译成本地的机器码,这些机器码也存储在本地方法栈当中。
在JVM规范中没有对这个区域严格限制,它可以由不同的JVM实现者自由实现,但是它和其他存储区一样也会抛出OutofMemoryError和StackOverflowError异常。
JVM内存分配区域
通常的内存分配策略
静态内存分配
栈内存分配
-
堆内存分配
静态内存分配是指在程序编译时就能确定每个程序在运行时的存储空间需求,因此在编译时就可以给他们分配固定的内存空间。这种分配策略不允许程序中存在可变数据结构(链表和动态数组等)和嵌套递归语句的出现,这些都会导致编译时无法准确得计算存储空间需求。
栈式内存分配也可称作动态存储分配,是由一个类似于栈的运行栈来实现的。和静态内存分配相反,在栈式内存方案中,程序对于数据区域的需求在编译时是完全未知的,只有在运行时才能知道,但是规定在运行中进入一个程序模块时,必须知道该程序模块所需要的数据区大小才能够为其分配内存。和我们熟悉的数据结构当中的栈一样,栈式内存分配按照先进后出的原则进行分配。
除了在编译时能确定数据的存储空间(静态内存分配)和运行时在程序入口处确定数据的存储空间(栈式内存分配)这两种外,还有一种情况就是当程序真正运行到相应代码时才会知道空间的大小,这时我们就需要堆这种分配策略。
这几种内存分配策略中,堆分配策略是最自由的,但是这种分配策略对操作系统和内存管理程序来说是一种挑战。另外,这个动态的内存分配是在程序运行时才执行的,它的运行效率也是比较差的。
Java中的内存分配详解(从分配区域角度)
从JVM内存结构来看。JVM内存主要基于两种,分别是堆和栈。
Java栈的分配是和线程绑定在一起的,当我们创建一个线程时,JVM会为这个线程创建一个新的Java栈,一个线程的方法的调用和返回对应于这个Java栈的压栈和出栈。每当线程调用了一个新的方法时,会在这个栈的顶部创建一个新的栈帧数据结构,这个栈帧自然成为当前帧。这个栈帧会保留这个方法的一些元信息,比如方法中定义的局部变量,正常方法返回以及异常处理机制等。栈中主要存放一些基本类型的变量数据(int,short,long,byte,float,double,boolean,char)和对象引用(句柄)。栈的优点是存取速度比堆要快,仅次于寄存器,栈数据可以共享。缺点是,存在栈中的数据大小和生存期必须是确定的,这也导致其缺乏灵活性。 (栈内存是JVM自动管理的,不需要GC回收机制,栈的内存随着函数的开始执行和结束自动分配和销毁)。
每个Java应用都唯一对应一个JVM实例,每个实例唯一对应一个堆。应用程序在运行中所创建的所有类实例或者数组都存放在这个堆中,并由应用程序所有的线程共享。建立一个对象时在堆和栈两个地方都要分配内存,在堆中分配的内存实际建立这个对象,在栈中分配的内存只是一个指向这个堆对象的指针。Java的堆是一个运行时数据区,堆是由GC机制来负责回收的。堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的对象.但缺点是,由于要在运行时动态分配内存,存取速度较慢。
综上,从堆和栈的功能和作用来通俗的比较,堆主要用来存放对象,栈主要用来执行程序!
Java中的内存分配详解(从硬件角度)
两种方式:
1.内存绝对规整(正在使用的区域和没有使用的区域分别在两边),用指针作为分界点的指示器,移动指针位置即可。——————“指针碰撞”方式
2.内存不规整,虚拟机必须维护一个列表,记录哪些内存块可以用,分配时在列表找到足够大空间分配给对象实例并更新维护列表。——————空闲列表
JVM内存回收策略
Java语言和其他语言一个很大的不同之处就是Java开发人员不需要了解内存这个概念,不像C或C++当中由malloc这种语法直接操作内存,在Java当中没有什么语法和内存直接有联系。但是任何语言都离不开内存的申请和回收,那Java又是如何做到的呢?就Java来说,内存的分配和回收主要有两种:一种是静态内存分配,另一种是动态内存分配。
静态内存分配和回收
在上文内存分配策略部分有讲到,在Java中静态内存分配是指在Java被编译时就已经能够确定需要的内存空间,当程序被加载时系统把内存一次性分配给它,这些内存不会再程序执行时发生变化,直到程序执行结束时内存才被回收。在Java的类和方法中的局部变量包括原生数据类型(int,char,long等等)和对象的引用都是静态分配内存的,这部分都储存在栈当中,如下边这段代码。
public void staticData(int arg)
{
String s="miao";
long l=1;
Long lg=1L;//注意这里的Long的L大写,是基本类型long的封装类Long,=后边的l表示lg是一个长整型,要不会默认为int类型的‘1’
Object o=new Object();
Integer i=0;
}
其中参数arg、l是基本数据类型,s,o,i和lg是指向对象的引用。在Javac编译时就已经确认了这些变量的静态内存空间。其中arg会分配四个字节,long会分配8个字节,String,Long,Object和Integer是对象类型,他们的引用会占用4个字节,所以整个方法占用的静态内存空间是4+8+4+4+4+4=28个字节。
静态内存空间当这段代码运行结束时回收,根据之前文章(JVM体系结构与工作方式)的介绍,静态内存空间是在栈上分配的,当这个方法结束时,对应的栈帧被销毁,分配的静态内存空间自动回收。
动态内存分配和回收
前面的例子中变量lg存储的值虽然和l变量一样,但是他们存储的位置是不一样的,后者是基本数据类型,存储在Java栈当中,方法执行结束就会被回收,前者是对象类型,存储在Java堆当中,他们是可以被共享的。变量l和lg的内存空间大小显然也是不一样的,l在java栈中被分配8个字节空间,lg在栈中被分配4个字节的地址指针空间,这个地址指针指向这个对象在堆中的地址。很显然在堆中long类型所占用的空间肯定比8个字节大,所以在代表相同数字时,Long所占用的空间要比long大的多。
在Java中对象的内存空间是动态分配的,所谓的动态分配就是在程序执行时才知道要分配的存储空间大小,而不是在编译时就能够确定的。lg代表的是Long对象,只有JVM在解析Long类时才知道这个类中有哪些信息,然后才能根据这些信息分配相应的存储空间存储相应的值。等到这个对象不再使用时会被JVM的GC机制回收。
那么如何确定这个对象什么时候不被使用,又如何来回收它们,这正是JVM很重要的一个组件————垃圾收集器要解决的问题。
如何检测垃圾
JVM中的堆和方法区主要用来存放对象(方法区中也存储了一些静态变量和全局变量等信息),那么我们要使用GC算法对其进行回收时首先要考虑的就是该对象是否应该被回收,我们需要将不被使用的对象标记出,以便GC回收.主要有引用计数法和可达性分析算法。
引用计数法
在对象头处维护一个counter,对象每被引用一次,counter++。如果对该对象的引用失联,则计数器自减,当count为0时,表明该对象已经被废弃,不处于存活状态,此时所占用的内存区域可以被GC回收。但是引用计数器存在两个比较明显的错误:1.这种方式无法区分软、虚、弱、强引用类型。2.会造成死锁,假设两个对象相互引用则始终无法释放counter,会造成死锁永远不会GC。
可达性分析法
通过一系列为GC roots的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC roots没有任何引用链相连时,则证明该对象是不可达的。它会被暂时标记上并且进行一次筛选,筛选的条件为是否有必要执行finalize()方法(在被GC回收之前需要执行的一个方法,在这个方法中要指定在一个对象被回收之前必须执行的操作)。如果被判定有必要执行finalize()方法,就会进入F-Queue队列中,并有一个虚拟机自动建立的,低优先级的线程去执行它。稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果此时仍是不可达的,那基本上就是真的被回收了。
哪些对象会被选作根节点呢?
- 虚拟机栈中引用的对象
- 方法区中类静态属性所引用的对象
- 方法区中常量引用的对象
- 本地方法区中native方法引用的对象
JVM在做垃圾回收时会检查堆中的所有对象是否都会被这些根对象直接或间接引用,能够被引用的对象就是活动对象,否则就可以被垃圾回收器回收。
再谈引用
1.强引用:类似于Object obj=new Object(),只要强引用还在,垃圾回收器永远不会回收掉被引用的对象。
2.软引用:在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。
3.弱引用:被弱引用的对象只能生存到下一次垃圾收集发生之前,不论到时内存是否足够。
4.虚引用:唯一目的是能在这个对象被收集器回收时收到一个系统通知。(一个对象是否有虚引用,不会对其生存时间构成影响。)
垃圾收集算法
1.标记——清除算法(老生代)
对每块内存区域进行检测,如果需要回收则打上标记。不足:标记和清楚两个过程的效率都不高;空间问题,会产生大量不连续碎片,当分配较大对象时,无法找到足够连续内存而不得不提前触发另一次GC操作。
2.标记——复制算法(新生代,新生代中的对象有98%都是“朝生夕死”的)
80% Eden,10% Survivor,10% Survivor 每次只使用Eden和另一块Survivor区域,GC之后对存活的对象进行判断,存活时间较长的移入老生代,存活期短的移入另一块Survivor区域(区域A),然后再将之前的那块Survivor区域(区域B)和Eden区域整块清除。在使用这块内存时,使用Eden区域和被移入的Survivor区域(区域A)。缺点:每次只能使用部分内存区域。
3.标记——整理算法(老生代)
让所有存活的对象都向一端移动,直接清除边界外的内存区域。