JVM内存管理
物理内存与虚拟内存
物理内存就是RAM(随机存储器)。在计算机中还有一个存储单元叫寄存器,它用于存储计算单元执行指令(如浮点数,整数等运算时)的中间结果。寄存器的大小决定了一次计算可使用的最大数值
连接处理器和RAM或者处理器和寄存器的是消息总线,这个消息总线的宽度影响了物理地址的索引范围,因为总线的宽度决定了处理器一次可以从寄存器或者内存中获取多少bit,同时也决定了处理器最大可以寻址的地址空间,如32位地址总线可以有232=4G的内存空间,64位可用264=16777216T的内存空间
我们要运行程序,都要通过操作系统提供的接口(系统调用)来申请内存地址。通常操作系统管理内存的申请空间是按照进程来管理的,每个进程拥有一段独立的地址空间,每个进程之间不会互相重合,操作系统也会保证每个进程只能访问自己的内存空间。这主要是从程序的安全性来考虑的,也便于操作系统来管理物理内存。
进程的内存空间的独立主要是指逻辑上独立,也就是这个独立是是由操作系统来保证的,但是真正物理空间是不是只能由一个进程来使用就不一定了。因为随着程序越来越庞大和设计的多任务性,物理内存无法满足程序的需求,在这种情况下就有虚拟内存的出现
虚拟内存的出现使得多个进程在同时运行时可以共享物理内存,这里共享只是空间上共享,在逻辑上它们仍然是不能互相访问的。虚拟内存不但可以让进程共享物理内存,提供高内存利用率,而且还能扩展内存的地址空间,如一个虚拟内存可能被映射到一段物理内存,文件或者其他可以寻址的存储上。一个进程在不活动的情况下,操作系统将这个物理内存中的数据移到一个磁盘文件中(也就是通常Windows系统上的页面文件,或者Linux系统上的交换分区),而真正高效的物理内存留给正在活动的程序使用。但是我们应该尽量避免这种情况经常出现,如果操作系统频繁地交换物理内存的数据和磁盘数据,则效率会非常低,尤其是在Linux服务器上,我们要关注Linux中swap分区的活跃度。如果swap分区被频繁使用,操作系统将会非常缓慢,很可能意味着物理内存已经严重不足或者某些程序没有及时释放内存
内核地址与用户地址
程序是不能完全使用计算机的内存空间,因为这些地址空间被划分为内核空间和用户空间。程序只能使用用户空间的内存。
内核空间主要是指操作系统运行时使用的用于程序调度,虚拟内存的使用或者连接硬件资源等程序逻辑。为何内存需要划分内核空间和用户空间?这是为了保证操作系统的稳定性,操作系统独立使用属于自己的内存空间;运行在操作系统上的软件不能访问操作系统所使用的内存空间,这也是从安全上考虑。
用户程序不能直接访问硬件,但是可以通过操作系统提供的接口来实现,这个调用接口的过程也就是系统调用。每一次系统调用都会存在两个内存空间的切换,这种从内核空间到用户空间的数据复制很费时,虽然保证了程序运行的安全性和稳定性,但是也牺牲了一部分性能
内核空间和用户空间的大小如何分配也是一个问题,是更多地分配给用户空间供用户程序使用,还是首先保证内核有足够空间来运行,这要平衡一下。在当前的Windows32位操作系统中默认内核空间和用户空间比例是1:1(2GB的内核空间,2GB的用户空间),而在32位操作系统Linux系统中默认的比例是1:3(1GB的内核空间,3GB的用户空间)
在Java中哪些主键需要使用内存
Java堆
Java堆是用于存储Java对象的内存区域,堆的大小在JVM启动时就一次向操作系统申请完成,通过-Xms和-Xmx两个选项控制大小,Xmx表示堆的最大大小,Xms表示初始大小。一旦分配完成,堆的大小就将固定,不能再内存不够时再向操作系统重新申请(所以会出现OOM),同时当内存空闲时也不能将多余的空间归还操作系统
在Java堆中内存空间的管理由JVM来控制,对象创建由Java应用程序控制,但是对象所占的空间释放由操作系统的垃圾回收器来完成。根据不同的垃圾回收(GC)算法的不同,内存的回收方式和时机也会不同
线程
JVM运行实际程序的实体是线程,线程需要内存空间来存储一些必要的数据。每个线程创建时JVM都会为它创建一个栈,栈的大小由不同的JVM实现而不同,通常在256k~756K之间
线程所占空间相比堆空间来说比较小,但是如果线程过多,线程的总内存使用量也可能非常大。当前有很多应用程序根据CPU的核数来分配创建的线程数
类和类加载器
在Java中类和类加载器也需要存储空间,在Oracle JDK中它们存储在堆中,这个区域被叫做永久代(PermGen区),在JDK新版本之后,这个空间被称为MeateSpace元空间,已经移出堆空间,独占一部分JVM空间
需要注意的是JVM是按需加载类的,JVM如果要加载一个jar包是否把这个jar包中的所有类都加载到内存中?显然不是,JVM只会加载那些在程序中明确使用的类到内存。要查看JVM到底加载了哪些类,可以在启动参数上加上-verbose:class
NIO
Java1.4版本之后添加了NIO类库,引入了一种通过管道和缓冲区来执行I/O的新方式。NIO中使用java.nio.ByteBuffer.allocateDirect()方式分配内存时,这样分配的内存使用的是本机内存而不是JVM内存,也就是说每一次分配都会调用操作系统的系统调用函数,这样不需要将数据从内核空间切换到用户空间,所以操作会快的多。但是回收只能作为Java堆GC的一部分来执行,因此它们不会自动响应本机内存上的压力,可能导致某些情况下的内存泄露
JNI
JNI技术使得本机代码(如C程序)可以调用Java方法,实际上Java本身也依赖JNI代码来实现类库功能,如文件操作,网络I/O操作或者其他系统调用。所以JNI也会增加Java运行时的本机内存占用
JVM内存结构
JVM是根据运行时数据的存储结构来划分内存结构的,运行时数据包括Java程序本身的数据信息和JVM运行Java程序需要的额外数据信息
在Java虚拟机规范中将Java运行时数据划分为6种:
- PC寄存器数据
- Java栈
- 堆
- 方法区
- 本地方法栈
- 运行时常量池
PC寄存器
PC寄存器用于保存当前正常执行的程序的内存地址,同时Java程序时多线程执行的,所以不可能一直都按照线性执行下去,当有多个线程交叉执行时,被中断现成的程序当前执行到哪条内存地址必然要保存下来,以便于它被恢复执行时再按照被中断时的指令地址继续执行下去,实际上也就是程序计数器
Java栈
Java栈总是和线程关联到一起的,每当创建一个线程时,JVM就会为这个线程创建一个对应的Java栈,在这个Java栈又会包含多个栈帧(Frames),这些栈帧是与每一个方法关联起来的,每运行一个方法就会创建一个栈帧(入栈),每个栈帧会包含一些局部变量,形参,操作账和方法返回值等信息
每当一个方法执行完成时,这个栈帧就会弹出栈帧的元素作为这个方法的返回值,并清除这个栈帧,Java栈的栈顶就是当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向这个地址
有于Java栈是与Java线程对应起来的,这个数据不是线程共享的,所以我们不用关心它的数据一致性,也不会存在同步锁的问题,也就是说方法中的局部变量不会有线程安全问题
堆
堆是存储Java对象的地方,它是JVM管理Java对象的核心存储区域,堆是Java程序员最应该关心的,因为它是我们的应用程序与内存关系最为密切的存储区域
每一个存储在堆中的Java对象都是这个对象类的一个副本,它会复制包括继承自它父类的所有非静态属性
堆是被所有Java线程所共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性
方法区
Java方法是用于存储类结构信息的地方,例如常量池,域,方法数据,方法体,构造函数,包括类中的专用方法,实例初始化,接口初始化都存储在这个区域
在JDK旧版本中方法区这个存储区域属于Java堆中的一部分,也就是我们我们说的永久区。这个区域可以被所有线程共享,而且它的大小可以通过参数来设置
这个方法区存储区域的大小一般在程序启动后一段时间后就是固定了,JVM运行一段时间后,需要加载的类通常已经加载到JVM中了,这个区域也会被JVM的GC回收器来管理
运行时常量池
在JVM规范中定义常量池包括几种常量:编译期的数字常量,方法或域的引用,这个常量池和方法区中所说的是一回事,它是方法区中的一部分,所以它的存储也受方法区的规范约束,如果常量池无法分配,同样会抛出OutOfMemoryError
本地方法栈
本地方法栈是为JVM运行Native方法准备的空间,也是采用栈的结构,由于很多Native方法是通过C语言实现的,所以它通常又叫C栈。在JVM利用JIT(Java即时编译)会将一些Java方法重新编译为Native Code代码,这些编译后代码通常也是利用这个栈来跟踪方法的执行状态
在JVM规范中没有对这个区域严格限制,它可以由不同的JVM实现者自由实现,但是它和其他存储区一样也会抛出OutOfMemoryError和StackOverflowError
操作系统内存分配
先来看下操作系统的内存分配策略:
- 静态内存分配
- 栈式内存分配
- 堆式内存分配
静态内存分配是指程序编译时就可以确定每一个数据在运行时的存储空间需求,因此在编译时就可以给它们分配固定的内存空间。但是这种策略不允许有可变数据结构存在(如可变数组),也不允许有嵌套或者递归的结构出现,因为它们会导致编译程序无法计算准确的存储空间
动态存储分配:栈式内存,堆式内存分配
栈式内存分配和静态内存分配相反,程序对数据区的需求只有到运行时才分配,但是在编译期必须可以确定数据大小和数据生存期。栈式内存分配按照先进后出的原则进行分配
在编写程序时除了在编译时能够确定数据的存储空间和在程序入口处能够知道存储空间外,还有一种一种情况就是当程序真正运行到相应代码时才会知道空间大小,这时就需要用堆式内存分配
堆式分配方式是最自由的,但是这种分配策略对操作系统和内存管理是一种挑战,因为这种方式在运行到对应代码时才分配内存,效率是这几种方式中最差的
Java内存分配
JVM内存分配主要基于两种:堆和栈
栈
Java栈是和线程绑定在一起的,当我们创建一个线程时,JVM就会为这个线程创建一个新的Java栈,一个线程的调用和返回值对应于这个Java栈的压栈和出栈,调用一个方法对应栈中的栈帧,在此方法执行过程中,这个栈帧将用来保存参数,局部变量,中间计算结果和其他数据
栈中主要存放一些基本数据类型数据(byte short int long float double char boolean)和对象句柄(对象引用变量)
优点:存取速度较快,仅次于寄存器,栈数据可以共享。缺点:存在栈中的数据大小与生存期必须是确定的,这也导致其缺少灵活性
堆
每个Java应用都对应一个JVM实例,每个实例唯一对应一个堆。应用程序在运行中所创建的所有对象实例或者数组都放在堆中,并且应用程序所有的线程共享,在Java中分配堆内存是自动初始化的,对象通过new关键字建立,而且不需要在程序中显式释放,通过垃圾回收器GC自动回收
建立一个对象时有两个地方会分配内存,在堆中分配内存实际上建立这个对象,而在栈中分配的内存只是一个指向这个堆对象的引用而已
优点:灵活,可以在动态分配内存大小,生存期也不需要事先告诉编译器。GC会自动会自动回收不再使用的数据。缺点:因为在运行时才计算所需要内存并创建,存取速度较慢
JVM内存回收策略
对于栈内存
当程序运行到对应位置时,系统一次性把内存分配,这些内存不会在程序执行过程中发生变化,直至执行结束时内存才被回收。在Java中类和方法中的局部变量包括基本数据类型和对象的引用都是这样分配的。当这个方法运行完毕后会出栈,对应的栈帧空间也就被回收了
对于堆内存
Java对象动态在堆内存中分配,也就是运行时才知道要分配的空间大小,然后根据类信息分配对应空间存储对应值,而这个对象什么时候被回收也是不确定的,只有等这个对象不在使用时才会被回收
堆内存的分配是在对象创建时发生的,而内存的回收是以对象不再被引用作为前提的
对象什么时候不被使用(可达性分析),又如何来回收它们(各种回收算法),这是垃圾回收器要解决的问题
如何检测垃圾
根对象:
- 在方法中局部变量区的对象引用
- 栈中的对象引用
- 常量池中的对象引用
- 本地方法区中的对象引用
- 类的Class对象
JVM在做垃圾回收时会检查所有对象是否可以被根对象直接或间接引用,能够被引用的对象就是活动对象,否则就可以被垃圾回收器回收
基于分代的垃圾回收算法
JVM将整个堆分为Young区(年轻代),Old区(年老代)和Perm区(永久代),分别存储不同年龄段的对象
- Young区可以分为1个Eden区(伊甸园区)和2个Survivor区(幸存区),其中所有新创建的对象都存在Eden区,当Eden区满后会触发minor GC将Eden区仍然存活的对象复制到一个Survivor区中,另外一个Survivor区中的存活的对象也将复制过来,始终保证有一个Survivor区是空的
- Old区存放的是Young区经过多次(默认15次)minor GC后存活的对象,或者一个大对象在Survivor区中无法放下也会直接直接放入Old区。当Old区空间不足就会触发Full GC
- Perm区主要放的是类的Class对象,如果一个类被频繁加载,也就可能导致Perm区满,Perm区的回收也是Full GC触发的
Sun对堆中不同代的大小给出建议,一般建议Young区大小为整个堆空间的1/4,而Young区中的Eden:S0:S1=8:1:1
Serial Collector (串行回收)
JVM在Clien模式下默认的GC方式,可以通过JVM配置参数-XX:+UseSerialGC
指定使用该收集算法
对整个堆内存的采用单线程回收,回收过程中应用程序会暂停
Parallel Collector (并行回收)
根据Minor GC和Full GC的不同分为三种:ParNew GC,Parallel GC,ParallelOld GC
- ParNewGC: 通过
-XX:+UseParNewGC
参数指定,它的对象回收策略同Serial Collector,但是是多线程并行回收 - ParallelGC:Server模式下默认的GC方式,可以通过
-XX:+UseParallelGC
参数指定,并行回收的线程数可以通过-XX:ParallelGCThreads
来指定,这个值有个计算公式,如果CPU核数小于8,线程数可以和核数一样,如果大于8,值为3+(cpu core * 5)/8
- ParallelOldGC:可以通过
-XX:UseParallelOldGC
参数指定,并行回收的线程数可以通过-XX:ParallelGCThreads
来指定,这个值计算公式同ParallelGC
ParallelOldGC与ParallelGC不同之处在于Full GC上,前者Full GC进行的动作为清空整个Heap堆中的垃圾对象,清除Perm区中已经卸载的类信息并进行压缩。而后者是清空Heap堆中的部分垃圾对象,并进行部分压缩
GC虽然是通过多线程并行执行,但是同样会暂停所有的应用程序
CMS Collector (并发回收)
可通过-XX:+UseConcMarkSweepGC
参数指定,并发线程默认4,可以通过-XX:ParallelCMSThreads=x
参数指定
基于使用率的回收。CMS GC与Minor GC,Full GC不同,它的触发规则是检查Old区或Perm区的使用率,当到达一定的比例后将触发 CMS GC。Old区这个比例通过-XX:CMSInitiatingOccupancyFraction=x
参数指定,默认92%。如果想让Perm区也是用CMS算法可以指定-XX:CMSClassUnloadingEnabled
来指定,比例也是通过-XX:CMSInitiatingOccupancyFraction=x
参数指定,默认92%
CMS何时执行JVM还有一些时机选择,如当前CPU是否繁忙等因素。它会根据一个技术规则动态调整,但是会带来一些额外开销,如果需要去掉这个动态调整,使用-XX:+UseCMSInitiatingOccupancyOnly
来禁止JVM自行触发CMS GC
这几种回收算法可以组合使用,但是不支持组合的方式有:1:-XX:+UseParNewGC -XX:+UseParallelOldGC 2: -XX:+UseParNewGC -XX:+UseSerialGC
GC参数
- -Xms 堆初始大小
- -Xmx 堆最大值
- -Xmn Young区大小
- -Xss 栈大小
- -XX:PermSize Perm区大小
- -XX:MaxPermSize Perm区最大值
- -XX:SurvivorRatio 默认8,表示Eden:s0
- -XX:MaxTenuringThreadold 默认15,代表对象在新生代经历多少次MinorGC后才晋升到Old区
三种GC优缺点对比
GC | 优点 | 缺点 |
---|---|---|
Serial Collector (串行) | 适合在内存有限的情况下 | 回收慢 |
Parallel Collector (并行) | 效率高 | 当Heap过大时,应用暂停时间较长 |
CMS Collector (并发) | Old区回收暂停时间短 | 产生内存碎片,整个GC时间比较长,比较耗CPU |
内存问题分析方案
GC日志
可以通过JVM启动时加上一些参数来记录当时的情况
- -verbose:gc,可以辅助输出一些详细的GC信息
- -XX:+PrintGCDetais,输详细的GC信息
- -XX:+PrintGCApplicationStoppedTime,输出GC造成程序暂停的时间
- -XX:+PrintGCDateStamps,GC发生的时间信息
- -XX:+PrintHeapAtGC,在GC前后输出堆中各个区域的大小
- -Xloggc:[file],将GC信息输出到单独的文件中
除了日志文件分析外,还可以直接通过JVM自带的一些工具分析,如jstat,使用格式:jstat -gcutil [pid] [counnt]
堆快照文件分析
可以通过命令 jmap -dump:formate=b,file=[filename][pid]
来记下下内存快照,然后使用第三方工具如mat来分析整个Heap情况
也可以在启动时添加JVM参数:-XX:+HeapDumpOnOutOfMemoryError
来配置当内存耗尽时记录下当时的内存快照,可以通过-XX:+HeapDumpPath
来指定这个文件命名格式如java_[pid].hprof
JVM Crash 崩溃日志
当系统内存不足时,或者JVM本身一些Bug都会导致JVM异常退出,JVM退出一般会在工作目录下产生一个日志文件,可以通过JVM参数-XX:+ErrorFile=/tem/log/xxx.log
来指定文件路径
这个崩溃日志文件中,JVM退出一般有三种主要原因:
- EXCEPTION_ACCESS_VIOLATION: 这个是运行JVM自己代码出错,很可能是JVM自己的bug
- SIGSEGV: JVM正在执行本地或JNI代码出错,这种错误可能是第三方的jar有问题
- EXCEPTION_STACK_OVERFLOW: 栈溢出错误,可以尝试通过
-Xss
参数调大栈尺寸