---------------------------------------------------
JVM内存管理分为两部分:
内存分配
内存回收
------------------------------------------------------
内存回收经常也被叫做垃圾回收。(附带资料:JVM面试题超链接、JVM性能调优 和 参数说明 )
*很多人迷惑一个问题,既然Java采用自动内存管理,程序员不用关心内存管理的细节,那么为什么我们仍然需要了解Java内存管理的内幕?
很简单:
1.了解Java内存管理的细节,有助于程序员编写出性能更好的程序。
比如,在新的线程创建时,JVM会为每个线程创建一个专属的栈(stack),其栈 是先进后出的数据结构,这种方式的特点,让程序员编程时,必 须特别注意递归方法要尽量少使用,另外栈的大小也有一定的限制,如果过多的递归,容易导致stack overflow。
2.了解Java内存管理的细节,一旦内存管理出现问题,有助于找到问题的根本原因所在。
3.了解Java内存管理的内幕,有助于优化JVM,使自己的应用获得最好性能体验。
本节要说的几个主要内容: 内存--- Java中哪些组件用到内存---内存分配机制---内存回收机制
1 内存
1.1 物理内存和虚拟内存
物理内存就是常说的RAM(随机存储器),操作系统作为我们管理计算机物理内存的接口,我们通常都是通过调用计算机操作系统来访问内存的,在Java中,甚至不需要写和内存相关的代码。
通常操作系统管理内存申请空间是按照进程来管理的,每个进程都有一段独立的空间,互不重合,互不访问。这里所说的内存空间的独立是指逻辑上的独立,由操作系统来保证的。
虚拟内存的出现使得多个进程可以同时运行时共享物理内存,空间上共享,逻辑仍然互不访问。虚拟内存提高了内存利用率,扩展了内存地址空间。
1.2 内核空间和用户空间
通常一个4GB的物理内存地址空间并不能完全被使用,因为它被划分为两部分:内核空间和用户空间
【内核空间】主要是指操作系统运行时用于程序调度、虚拟内存的使用或者连接硬件资源的程序逻辑。
【用户空间】是用户运行程序能够申请使用的空间。
为什么这么划分呢?
1)有效抵御恶意用户的窥探,也能防止质量低劣的用户程序的侵害,从而使系统运行得更稳定可靠。
2)用户空间与内核空间的权限不同,内核空间拥有所有硬件设备的权限,用户空间只有普通硬件的权限;两者隔离可以防止用户程序直接访问硬件资源。
但是,每一次系统调用都会在两个内存空间之间切换,通过网络传输的数据首先被接收到内核空间,然后再从内核空间复制到用户空间供用户使用。这样比较费时,虽然保证了程序运行的安全性和稳定性,但同时也牺牲了一部分效率。(后来出现了一些列优化技术,如Linux提供的sendfile文件传输方式)
另外:
Windows32位操作系统【内核空间:用户空间=1:1】
Linux32位操作系统【内核空间:用户空间=1:3】
Java中的内存就是从物理内存中申请下来的内存,它怎么被划分的呢!?
2 Java内存模型
2.1 Java程序执行流程
一个Java程序的具体执行流程如下:
首先Java源代码文件(.java后缀)会被Java编译器编译为字节码文件(.class后缀),然后由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行。在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。
2.2 Java内存划分
我们常说的Java内存管理就是指这块区域的内存分配和回收,那么,这块儿区域具体是怎么划分的呢?
根据《Java虚拟机规范》的规定,运行时数据区通常包括这几个部分:
程序计数器(ProgramCounter Register)
Java栈(VM Stack)
本地方法栈(Native MethodStack)
方法区(Method Area)
堆(Heap)
Java堆和方法区是所有线程共享(所有执行引擎可访问);
【Java堆】用于存储Java对象,每个Java对象都是这个对象类的副本,会复制包含继承自它父类的所有非静态属性。
【方法区】用于存储类结构信息,class文件加载进JVM时会被解析成JVM识别的几个部分分别存储在不同的数据结构中:常量池、域、方法数据、方法体、构造函数,包括类中的方法、实例初始化、接口初始化等。
方法区被JVM的GC回收器管理,但是比较稳定,并没有那么频繁的被GC回收。
java栈和PC寄存器(程序计数器)是线程私有,每个执行引擎启动时都会创建自己的java栈和PC寄存器;
【Java栈】和线程关联,每个线程创建的时候,JVM都会为他分配一个对应的Java栈,这个栈含有多个栈帧;栈帧则是个方法关联,每个方法的运行都会创建一个自己的栈帧,含有内存变量,操作栈、方法返回值。
(用于存储方法参数、局部变量、方法返回值和运算中间结果)
【PC寄存器】则用于记录下一条要执行的字节码指令地址和被中断地址。如果方法是 native的,程序计数器的值不会被定义为空。
【本地方法栈】是为JVM运行Native方法(本地方法:非java语言编写的方法,被编译成和处理器相关的代码)准备的空间,类似于Java栈。
【运行时常量池】关于这个东西要明白三个概念:
- 常量池(Constant Pool):常量池数据编译期被确定,是Class文件中的一部分。存储了类、方法、接口等中的常量,当然也包括字符串常量。
- 字符串池/字符串常量池(String Pool/String Constant Pool):是常量池中的一部分,存储编译期类中产生的字符串类型数据。
- 运行时常量池(Runtime Constant Pool):方法区的一部分,所有线程共享。虚拟机加载Class后把常量池中的数据放入到运行时常量池。
3 Java中哪些组件用到内存
Java堆
Java堆用于存储Java对象,在JVM启动时就一次性申请到固定大小的空间,所以,一旦分配,大小不变。
- 内存空间管理:JVM
- 对象创建:Java应用程序
- 对象所占空间释放:垃圾收集器
线程
JVM运行实际程序的实体就是线程,每个线程创建的时候JVM都为它创建了私有的堆栈和程序计数器(或者叫做PC寄存器);很多应用程序是根据CPU的核数来分配创建的线程数。
类和类加载器
Java中的类和类加载器同样需要存储空间,被存储在永久代(PermGen区)当中。
JVM加载类方式:按需加载,只加载那些你在程序中明确使用到的类,通常只加载一次,如果一直重复加载,可能会导致内存泄露,所以也要注意对PernGen区失效类的卸载内存回收问题。
通常PernGen区满足内存回收的条件为:
1) 堆中没有对该类加载器的引用;(java.lang.ClassLoader对象)
2) 堆中没有对类加载器加载的类的引用;(java.lang.Class对象)
3) 该类加载器加载的类的所有实例化的对象不再存活。
NIO
NIO使用java.nio.ByteBuffer.allocateDirect()方法分配内存,每次分配内存都会调用操作系统函数os::malloc(),所以,分配的内存是本机的内存而不是Java堆上的内存;
另外利用该方法产生的数据和网络、磁盘发生交互的时候都是在内核空间发生的,不需要复制到用户空间Java内存中,这种技术避免了Java堆和本机堆之间的数据复制;但是利用该方法生成的数据会作为Java堆GC的一部分来自动清理本机缓冲区。
JNI
JNI技术使本机代码可调用java代码,Java代码的运行本身也依赖于JNI代码来实现类库功能,所以JNI也增加内存占用。
4 JVM内存分配与回收
4.1 内存分配
4.1.1 通常的内存分配策略
操作系统中内存分配策略通常分为三类:
【静态内存分配】编译时就分配了固定的内存空间(编译器确定所需空间大小),不允许有可变数据和递归嵌套等情况,这样难以计算具体空间;
【栈内存分配】在程序运行时进入一个程序模块(程序入口处确定空间大小)知道一个程序模块分配所需数据区大小并为之分配内存。
【堆内存分配】在程序运行到相应代码是才会知道所需空间大小。(运行时确定空间大小)
很明显,三种分配策略中,堆内存分配策略最自由,但是效率也是比较差的。
4.1.2 Java内存分配详解
在Java程序运行过程中,JVM定义了各种区域用于存储运行时数据。其中的有些数据区域在JVM启动时创建,并只在JVM退出时销毁;其它的数据区域与每个线程相关。这些数据区域,在线程创建时创建,在线程退出时销毁。
栈和线程
JVM是基于栈的虚拟机,为每个新创建的线程都分配一个栈,也就是说一个Java程序来说,它的运行就是通过对栈的操作来完成的。栈以帧为单位保存线程的状态。JVM对栈只进行两种操作:以帧为单位的压栈和出栈操作。
某个线程正在执行的方法称为此线程的当前方法,当前方法使用的帧称为当前帧。当线程激活一个Java方法,JVM就会在线程的 Java堆栈里新压入一个帧。这个帧自然成为了当前帧.在此方法执行期间,这个帧将用来保存参数,局部变量,中间计算过程和其他数据。这个帧在这里和编译原理中的活动纪录的概念是差不多的。
从Java的这种分配机制来看,可以这样理解:栈(Stack)是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个线程建立的存储区域,该区域具有先进后出的特性。
堆和栈的区别
1. 栈(stack)与堆(heap)都是Java用来在Ram中存放数据的地方 。与C++不同,Java自动管理栈和堆,程序员不能直接地设置栈或堆。
2. 栈的优势是,存取速度比堆要快 ,仅次于直接位于CPU中的寄存器,缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。另外,栈数据可 以共享,详见第4点。
堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取速度较慢。
3.两者存储数据类型不同
堆是一个运行时数据区,存放通过new、newayyray.anewarray和mulitanewarray等指令建立的对象,无需代码显式的释放;
栈中存放一些基本类型的变量数据(int/short/long/byte/float/double/Boolean/char)和对象句柄(引用);
Java中所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配;也就是说在建立一个对象时从两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)。
4.2 JVM内存回收
4.2.1 几个问题要搞清楚
问题一:什么叫垃圾回收机制?
垃圾回收是一种动态存储管理技术,它自动地释放不再被程序引用的对象,按照特定的垃圾收集算法来实现资源自动回收的功能。当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用,以免造成内存泄露。
问题二:java的垃圾回收有什么特点?
Java语言不允许程序员直接控制内存空间的使用。内存空间的分配和回收都是由JRE负责在后台自动进行的,尤其是无用内存空间的回收操作(garbagecollection,也称垃圾回收),只能由运行环境提供的一个超级线程进行监测和控制。
问题三:垃圾回收器什么时候会运行?
一般是在CPU空闲或空间不足时自动进行垃圾回收,而程序员无法精确控制垃圾回收的时机和顺序等。、
问题四:什么样的对象符合垃圾回收条件?
当没有任何获得线程能访问一个对象时,该对象就符合垃圾回收条件。
问题五:垃圾回收器是怎样工作的?
垃圾回收器如发现一个对象不能被任何活线程访问时,他将认为该对象符合删除条件,就将其加入回收队列,但不是立即销毁对象,何时销毁并释放内存是无法预知的。垃圾回收不能强制执行,然而java提供了一些方法(如:System.gc()方法),允许你请求JVM执行垃圾回收,而不是要求,虚拟机会尽其所能满足请求,但是不能保证JVM从内存中删除所有不用的对象。
问题六:一个java程序能够耗尽内存吗?
可以。垃圾收集系统尝试在对象不被使用时把他们从内存中删除。然而,如果保持太多活动对象,系统则可能会耗尽内存。垃圾回收器不能保证有足够的内存,只能保证可用内存尽可能的得到高效的管理。
问题七:程序中的数据类型不一样存储地方也不一样,原生数据类型存储在java栈中,方法执行结束就会消失;对象类型存储在Java堆中,可以被共享,不一定随着方法执行结束而消失。
问题八:如何检测垃圾?(垃圾检测机制)
垃圾收集器的两个任务:正确检测出垃圾对象和释放垃圾对象占用的内存空间,而前者是关键所在。
垃圾收集器有一个根对象集合,包含的元素:1)方法中局部变量的引用;2)Java操作栈中的对象引用;3)常量池中的对象引用;4)本地方法持有的对象引用;5)类的class对象。
JVM在垃圾回收的时候会检查堆中的所有对象是否会被根对象直接或间接的引用,能够被根对象到达的叫做活动对象,否则叫做非活动对象可以被回收。
4.2.2 内存回收- gc原理
jvm内存回收采用的是基于分代的垃圾收集算法
Sun的JVM Generational Collecting(垃圾回收)原理是这样的:把对象分为年青代(Young)、年老代(Tenured)、持久代(Perm),对不同生命周期的对象使用不同的算法。(基于对象生命周期分析)
【设计思路】:把对象按照寿命长短来分组,分为年轻代和年老代,新创建的对象被分在年轻代,如果对象经过几次回收后仍然存活,那么再把这个对象划分到年老代。年老代的收集频度没有那么频繁,这样就减少了每次垃圾收集时所需要的扫描的对象和数量,从而提高垃圾回收效率。
1.Young(年轻代)
年轻代分三个区。一个Eden区,两个Survivor区。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制年老区(Tenured,需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden和Survivor区复制过来的对象,而复制到年老区的只有从第一个Survivor过来的对象,而且,Survivor区总有一个是空的。
2.Tenured(年老代)
年老代存放从年轻代存活的对象。一般来说年老代存放的都是生命期较长的对象;如果Tenured区(old区)也满了,就会触发Full GC回收整个堆内存。
3.Perm(持久代)
用于存放类的Class文件或静态文件,如Java类、方法等,垃圾回收是由FullGC触发的。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。
举个例子:
当在程序中生成对象时,正常对象会在年轻代中分配空间,如果是过大的对象也可能会直接在年老代生成(据观测在运行某程序时候每次会生成一个十兆的空间用收发消息,这部分内存就会直接在年老代分配)。年轻代在空间被分配完的时候就会发起内存回收,大部分内存会被回收,一部分幸存的内存会被拷贝至Survivor的from区,经过多次回收以后如果from区内存也分配完毕,就会也发生内存回收然后将剩余的对象拷贝至to区。等到to区也满的时候,就会再次发生内存回收然后把幸存的对象拷贝至年老区。
通常我们说的JVM内存回收总是在指堆内存回收,确实只有堆中的内容是动态申请分配的,所以以上对象的年轻代和年老代都是指的JVM的Heap空间,而持久代则是之前提到的MethodArea,不属于Heap。
关于JVM内存管理我们需要注意的几个地方:
1、程序中的无用对象、中间对象置为null,可加快内存回收。
2、对象池技术如果生成的对象是可重用的对象,只是其中的属性不同时,可以考虑采用对象池减少对象的生成。
如果对象池中有空闲的对象就取出使用,没有则生成新的对象,提高对象复用率。
3、JVM性能调优通过配置JVM的参数来提高垃圾回收的速度,如果在没有出现内存泄露且上面两种办法都不能保证JVM内存回收时,可以考虑采用JVM调优 的方式来解决,不过一定要经过实体机的长期测试,因为不同的参数可能引起不同的效果。如-Xnoclassgc参数等。
4.2.3 jvm的垃圾回收算法
Java中,垃圾回收(GC,Garbage Collection)的对象是Java堆和方法区(即永久区或持久区)
垃圾指的是在系统运行过程当中所产生的一些无用的对象,这些对象占据着一定的内存空间,如果长期不被释放,可能导致OOM。后台专门有一个专门用于垃圾回收的线程来进行监控、扫描,自动将一些无用的内存进行释放,这就是垃圾收集的一个基本思想,目的在于防止由程序猿引入的人为的内存泄露。
现代java虚拟机常用的垃圾回收算法有三种,分别是标记-清除算法、复制算法、标记-整理算法
1 标记-清除算法
(1)概念:
标记-清除算法是现代垃圾回收算法的思想基础。它将垃圾回收分为两个阶段:标记阶段和清除阶段。
标记阶段:首先,通过根节点,标记所有从根节点开始的可达对象。未被标记的对象就是未被引用的垃圾对象;
清除阶段:然后,清除所有未被标记的对象。
(2)算法详解
原理:当堆中的可用有效内存空间(available memory)被耗尽的时候,就暂停整个程序(也被成为stop the world),然后进行标记和清除两项工作,然后让程序恢复运行。
- 标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。
- 清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
疑问:为什么非要停止程序的运行呢?
答:不难理解,假设程序与GC线程一起运行,当对象A处于标记阶段,被标记为垃圾对象后,试想此时新new了一个对象B,且对象A可达B。但是由于此时A对象已经标记结束,B对象错过了标记阶段。因此当接下来清除阶段会被,新对象B会随着A被标记被清除掉,变为null,这样就乱套了。如此一来,要想正常清除垃圾资源,GC线程必须要暂停程序。
(3)标记-清除算法的缺点:
首先,它的缺点就是效率比较低(递归与全堆对象遍历),暂停程序stop the world的时间比较长。(尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?)
第二则是这种方式清理出来的空闲内存不连续,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
2 复制算法(适用于年轻代GC)
(1)概念:内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
- 与标记-清除算法相比,复制算法是一种相对高效的回收方法,且内存连续。
- 不适用于存活对象较多的场合,如老年代(复制算法适合做新生代的GC)
(2)优点:实现简单,运行高效,内存连续。每次只要一动指针,就可联系分配内存存放复制过来的对象。
缺点:空间浪费,只用了一半内存,所以,要想用复制算法,最起码对象的存活率要非常低才行,而且最重要的是要克服50%内存的浪费。
针对这种缺点,这种算法比较适合,且已经用于年轻代垃圾回收,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖于老年代进行分配担保,所以大对象直接进入老年代。整个过程如下图所示:
上图中,绿色箭头的位置代表的是大对象,大对象直接进入老年代。
3 标记-整理算法(适用于年老代的GC)
(1)引入
如果在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选中这种算法。
(2)概念: 适合用于存活对象较多的场合,如老年代。它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记;但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间。
- 标记:它的第一个阶段与标记/清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
- 整理:移动所有存活的对象,按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。(JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销)
(3)
优点:标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
缺点:就是效率也不高。不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,要低于复制算法。
4 标记-清除算法、复制算法、标记整理算法的总结
三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域,而不应该使用C/C++式内存管理方式。
在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。
它们的区别如下:
(1)效率:复制算法 > 标记/整理算法 > 标记/清除算法
(2)内存整齐度:复制算法=标记/整理算法>标记/清除算法
(3)内存利用率:标记/整理算法=标记/清除算法>复制算法
注1:可以看到标记/清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。
注2:时间与空间不可兼得。