微信公众号【黄小斜】大厂程序员,互联网行业新知,终身学习践行者。关注后回复「Java」、「Python」、「C++」、「大数据」、「机器学习」、「算法」、「AI」、「Android」、「前端」、「iOS」、「考研」、「BAT」、「校招」、「笔试」、「面试」、「面经」、「计算机基础」、「LeetCode」 等关键字可以获取对应的免费学习资料。
欢迎阅读我的专栏:JavaWeb技术世界
’与其他高级语言不一样,在Java中基本上不会显示地调用分配内存的函数,我们甚至不用关心到底哪些程序指令需要分配内存,哪些不需要分配内存。
我们首先需要从操作系统层面理解物理内存的分配和Java运行的内存分配之间的关系。
物理内存与虚拟内存
1 物理内存就是RAM,还有一个存储单元叫做寄存器,连接处理器和RAM或者寄存器的是地址总线,这个地址总线的宽度影响了物理地址的索引范围,32位地址总线可以寻址4gb内存。
2 除了硬件程序或者驱动程序需要直接访问存储器外,大部分情况下都是通过操作系统提供的接口来访问内存,在java中甚至不需要写和内存相关的代码。
3 不管是在windows还是linux,我们运行程序都要向操作系统申请内存地址,通常操作系统管理内存的申请空间是按照进程来管理的。每个进程拥有一个独立的地址空间,逻辑上是隔离的。
4 但是由于系统内存有限,物理空间需要得到更有效的利用,于是进程在物理上是共享物理内存的。
5 为了实现这一目的,操作系统设计了虚拟内存的概念,每个进程有一个很大的虚拟地址空间,虚拟地址可以让进程共享物理内存,提高利用率,也可以让进程独享大段虚拟地址空间。
页面和页面置换
进程在不活动的时候,把物理内存中的数据存在磁盘文件中。在windows中就是页面文件,在linux中则是swap分区。真正高效的物理内存留给正在活动的程序使用。
当我们唤醒一个很长时间没有使用的程序时,磁盘会吱吱作响,因为此时在进行页面调度,这种情况需要避免经常出现,否则效率会很低。如果linux上的swap分区被频繁使用,则系统会非常慢,说明物理内存可能严重不足或者某些程序没有释放内存。
内核空间和用户空间
计算机有一定大小的内存空间,比如4GB,这些地址空间并不能被用户程序完全使用。因为有一部分空间是内核空间。
内核空间主要是操作系统运行时所使用的用于进程调度,虚拟内存的使用或者硬件资源等的程序逻辑。
为了保证系统的稳定性和安全性,必须划分为两个空间。
访问硬件资源如网络逻辑,执行IO读取磁盘数据等操作,都需要通过系统调用来完成,系统调用就是执行操作系统提供的接口。
而操作系统和硬件之间的交互一般是基于指令集来完成的,机器码根据指令集进行译码,cpu根据指令完成对应的操作,比如访问内存,执行IO操作,以及访问硬件等等。
系统调用
执行系统调用的时候,需要进行两个内存空间的切换,经常需要在两个内存空间之间进行内容复制。这牺牲了一部分效率,Linux系统sendfile文件传输方式,可以减少这种内核空间到用户空间的数据复制方式。
Java中哪些组件需要使用内存
java堆
java堆用于存储java对象,通过-xmx表示堆的最大值,xms表示初始大小,一旦分配完成,堆空间就固定了。不能重新申请。
线程
jvm运行程序的实体是线程,只有java这一个进程代表jvm实例。
线程需要要内存空间来存储一些必要数据,每个线程创建时jvm会为他创建一个堆栈,堆栈的大小根据不同的jvm实现而不同。
通常在256k到756k之间。
线程所占空间比堆空间来说比较小,但使用量可以非常大。
类和类加载器
永久代以前的实现是方法区,现在改为元数据区。
jvm是按需加载类的,如果要加载一个jar包是否把这个jar包中所有的类加载到内存中!??
如果是这样,那么一个很大的jar包,如果我们只使用一个类,却需要全部加载,也太浪费了把。
显然不是的,jvm只会加载那些在你的应用程序中明确使用的类到内存中,要查看jvm到底加载了哪些类,可以在启动参数后加上-verbose:class
类加载的内存泄漏
如果使用自定义类加载器加载类,可能会出现重复加载的情况,如果方法区不能对失效类进行卸载,则可能导致方法区内存泄漏。
一个类A能够被卸载的必要条件如下
1:在Java堆中没加载有A的classloader对象的引用。
2:java堆上没有加载A的classloader已加载的类的class对象引用
3:java堆上没有加载A的类加载器加载的任何类的对象
由于jvm创建的三个默认类加载器都不可能满足这些条件,所以任何系统类加载器加载的类都不能在运行时被释放。
NIO
java在1.4后添加了NIO
1 开始支持使用allocatedirect方法为bytebuffer分配内存。
这个方法分配内存使用的是本机内存而不是Java堆上的内存。
2 直接操作这个bytebuffer时不需要进行java内存和本机内存的复制,比起Java内存和内核空间的复制效率要快得多。
3 直接bytebuffer对象会自动清理已分配的本地内存(缓冲区内存),但这个过程只能作为java堆gc的一部分来执行,没办法单独执行。
4 只有在堆内存满时发生GC或者显示调用system.gc时才能释放这部分直接内存,所以会增加gc的次数。
5 如果进制system.gc,则有可能导致直接内存泄漏
JNI
JNI技术使得java代码可以调用本机代码(比如c语言程序)。
这部分用到了native memory,也就是本地内存。
JVM内存结构
pc寄存器(程序计数器)
由于Java是多线程应用。线程经常被中断,为了恢复时记住上次运行到哪,需要一个程序计数器。
Java栈
1 Java的栈和线程关联在一起。
2 每个线程有一个栈。
3 每运行一个方法就创建一个新栈帧。
4 栈帧包含了内部变量(方法内部的变量,不是方法参数,方法参数用调用者传来),操作数栈,方法返回值等信息。
5 每个方法执行完成时,每个栈帧都会弹出栈帧的元素作为方法的返回值。
6 java栈的栈顶就是当前的活动栈,pc寄存器会指向这个地址。
堆
堆是存储对象的地方,每一个存储在堆中的对象都是这个对象类的一个副本,它会复制包括继承它的父类的所有非静态属性。
注意是非静态属性,静态属性编译时确定,存在类的元数据中,在方法区。
方法区
方法区是存储类结构信息的地方。
class文件被加到jvm中时,会被存储在不同的数据结构中。
其中类的常量池,域,方法数据,方法体,构造函数,类的静态方法等代码都存在这里。
方法区存储的数据比较稳定,不会被频繁回收。
运行时常量池
jvm中定义运行时常量池是在方法区中的一部分。
它代表着运行时每个class文件中的常量表,它包括几种常量,编译期的数字常量,方法或者域的引用(在运行时解析连接)。
本地方法栈
本地方法栈是伪jvm运行native方法准备的空间,和java栈的作用类似,也叫c栈,代码中的native方法调用会使用这个存储空间,在jvm利用JIT方法编译为native代码后,也会通过这个栈来跟踪和执行方法。
jvm内存分配策略
通常分配策略
操作系统把内存分配策略分为三种
1 静态内存分配
编译期确定分配空间
2 栈内存分配
内存需求完全未知,动态分配
3 堆内存分配
堆内存分配会划分一定空间为堆内存,然后根据代码需要动态分配空间
java中的内存分配详解
1 java的分配和线程绑定在一起,栈的本地变量表通过slot数组来分配,可以复用,由于栈帧的大小确定,所以编译期就可以确定大小,进行内存分配。
栈中主要存放基本类型的变量数据,和对象引用,读取速度较快,仅次于寄存器。
2 应用程序所创建的UI想或者数组都在堆中,并且能够共享。
建立对象时在堆中分配实例,在对应栈中分配引用。
由于堆要动态分配内存,所以存取速度较慢。
jvm内存回收策略
静态内存分配和回溯
java的静态内存在栈上分配,方法运行结束后对应栈帧也就消失了,所以静态内存空间也就回收了。
动态内存分配和回收
对象不再被使用时会被回收,这是垃圾回收器要解决的问题。
如何检测垃圾
可达性分析:从GC ROOT出发标记活动集合,剩下的不可达对象就是垃圾
1 方法内部变量对对象的引用
2 java栈中的对象引用
3 常量池中的对象引用,比如对字符串对象的引用
4 本地方法中持有的对象引用
总结一下就是从四个方面找到正在活动的对象引用,比如方法内部,栈中引用,常量池中引用,以及本地方法的引用。
基于分代的垃圾收集算法
1 young区分为eden和两个survivor区。初始分配到eden区,存活对象进入survivor一个区,这两个survivor保证有一个是空的。
2 old区存放young区的survivor满触发minor gc后仍然存活的对象。如果old区也满则触发full gc。
3 方法区存放类的元数据信息,可以被full gc回收。
visualvm工具的visualgc插件可以观察到不同代的垃圾回收情况。比如占用率,空闲内存,回收次数,耗时情况等。
一般建议young区为整个堆的1/5;survivor和eden是2:8的关系,也就是eden:from:to = 8:1:1
垃圾收集器
略
serial collector
parallel collector
CMS collector
内存问题分析
gc日志分析
有时候我们不知道何时会发生内存溢出,但是发生时我们不知道原因,所以在jvm就加上一个参数可以记下一些当时的情况。
gc日志输出如下参数
-verbose:gc,可以辅助输出gc详细信息
-XX:+printGCDetails,输出gc的详细信息
一般会输出
1 回收器名称
2 各区内存的使用情况和gc后的使用情况
3 各区进行gc的停顿时间
4 整个堆在gc前后的内存
5 gc过程中jvm暂停的总时间
jvm自带工具分析
jstat -gcutil
可以分析一些GC的情况
1 各分区的使用空间情况
2 永久代内存情况
3 young gc和 full gc的次数和所用时间
堆快照文件分析
通过jmap -dump 可以用来记下堆的文件快照。然后利用第三方工具如mat来分析整个Heap的对象关联情况
jvm crash日志分析
jvm有时会因为一些原因垮掉,因为jvm也是一个程序,可能会出bug导致异常退出。jvm退出会在工作目录产生一个日志文件。使用-XX:ErrorFile可以转储该文件。
一般有三种原因导致退出
1 exception——access——violation
jvm运行自己的代码时出错。
2 sigsegv
jvm正在执行JNI代码时出错
3 exception——stack——overflow
这个栈溢出不是java线程的栈溢出,因为线程的栈溢出只会抛出Stack Overflow exception。
这个主要是因为jvm的代码是c++代码,如果自身运行时栈溢出也会导致jvm退出。
实例分析1:内存泄漏
问题:
1 系统负载偏高,达到6,平时基本为1
2 整个系统响应较慢,重启之后恢复正常
解决思路:
PS科普:CMS GC时出现promotion failed跟concurrent mode failure
对于采用CMS进行旧生代GC的程序而言,尤其要注意GC日志中是否有promotion failed和concurrent mode failure两种状况,当这两种状况出现时可能会触发Full GC。
promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入旧生代,而此时旧生代也放不下造成的;concurrent mode failure是在执行CMS GC的过程中同时有对象要放入旧生代,而此时旧生代空间不足造成的。
所以在别一次性占用太多的内存,如果是读文件,可以采用拆分的方法。或者把GC内存调大点
1 查看GC,FGC明显异常,过程中出现了concurrent mode failure。说明在old区分配内存时出现了失败的情况。(多个线程触发young gc,导致多个对象同时进入老年代失败)
2 整个内存占用达到了6gb,超出了平时的4gb,得出可能是出现了内存泄漏的问题。
3 通过jmap -dump 命令查看heap,再通过mat工具分析。
发现一个最大的对象占用了900M内存,显然有问题。
根据mat给出的说明,整个map集合占用了55%的空间
4 再看一下这个对象持有了哪些对象。
仔细查看后发现map持有一个大对象,它的大小没有超出我们预期,但是仔细看其他集合,没有发现所持对象有什么不对的地方。
但是仔细计算整个对象集合的大小发现,对象全都存在,但是大小比正常多了将近一倍,于是想到可能是持有了相同的对象。
5 搜索完集合后发现确实如此,原来是因为业务逻辑要求每天凌晨更新一次老对象,更新后老对象自动释放,但是我们的新引擎是要保存这些对象,以便做编译优化,不能及时释放老对象,导致大对象保存了两份。
实例分析2:jvm bug
问题:
1 淘宝某应用突然导致线上机器报警,Java内存使用验证,达到6gb,超过4gb,而且有几台运行一段时间后导致OOM,JVM退出。
2 观察重启后的机器,发现应用恢复正常,但是发现JVM进程占用的内存一直在增加,大体推断是有内存泄漏。
解决思路:
1 检查了一下最近一周代码更新没有发现内存泄漏的代码。
2 同时检查gc日志,发现 有问题的机器的full gc没有异常,甚至比平时的full gc次数少,cms gc也很少。
3 为了进一步确认gc是否正常,我们找出jvm的heap,用mat分析对文件,整个heap只有不到1g的空间。最大的对象和符合预期,所以不是jvm的堆内存有问题,但是既然堆占用的内存并不多,那为什么java进程占用这么多内存?
4 于是想到了可能是堆外内存的泄漏,也就是本地内存,JIT编译需要本地内存,jvm栈需要本地内存,JNI调用本地代码也需要本地内存存,NIO也会使用Direct buffer申请本地内存。
5 我们用到了direct buffer,于是怀疑它。因为上次引入了mina包,使用了direct buffer,但是为什么direct buffer会回收异常呢。
6 这是想到了一个jvm的bug。
如果使用 -XX:+DisableExplicitGC参数启动jvm,但是又用到了direct buffer,会导致system.gc失效,与此同时我们应用自己触发的gc次数很少,导致directbuffer没有机会被回收,
其分配的本地内存无法被释放。
7 解决办法就是出去 -XX:+DisableExplicitGC,换上-XX:+DisableGCInvokesConcurrent,使得外部的system.gc能够调用成功。
PS:降低每次Full gc的时间
通过ExplicitGCInvokesConcurrent选项,可以使用CMS收集器来触发Full gc
如果系统中大量使用了DirectByteBuffer,需要定期地对native堆做清理,清理时可以使用Full gc,也可以使用CMS,视QPS情况而定;
可以使用-Dsun.rmi.dgc.server.gcInterval=7200000与-Dsun.rmi.dgc.client.gcInterval=7200000 选项控制Full gc时间间隔;
可以使用-XX:DisableExplicitGC选项禁用显示调用Full gc;
如果希望Full gc有更少的停机时间,可以启用-XX:+ExplicitGCInvokesConcurrent或-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses选项。
实例分析3:另一个NIO direct buffer内存泄漏
问题:
1 系统运行20到30分钟后,系统swap区突增,直到swap区使用率达到100%,机器死机。
2 系统内存达到3.5g,已经超过了heap堆设置的上限,但是gc缺很少。
3 old区的空间也几乎没有变化
解决思路:
1 Java进程内存增长非常迅速,进行压测20分钟后就将2gb内存耗光,并且内存耗光后开始使用swap区,很快消耗了swap区的空间,最终导致机器死机,所以可以肯定是内存泄漏。
2 通过jstat分析jvm heap情况和gc统计信息,发现gc很少,full gc几乎没有,jvm堆内存被耗光的话,Full gc应该非常频繁,所以初步判断这次内存泄漏不在堆中
3 进一步排除是jvm堆内存问题,使用jmap dump出内存快照,通过mat分析内存情况。
4 可以得出要么是nio 本地内存泄漏,要么是native memory泄漏。
使用工具检测direct memory占用的空间大小。
发现并不是很多,所以怀疑是native memory出现泄漏。
5 使用oprofier热点分析工具分析当然系统执行的热点代码,如果是当前的native memory泄漏,那么肯定会出现分配内存的代码是热点的情况。
6 发现和预想的情况并不吻合,于是进行功能拆分,查看到底是哪个模块导致泄漏。拆分几次后发现时mina框架给varnish发送失败请求时导致的,而且发送的请求频率越高内存泄漏越严重。但是mina框架没有使用native memory的地方。
科普ps:Varnish
Varnish与一般服务器软件类似,就是一个web缓存代理服务器,分为master(management)进程和child(worker,主要 做cache的工作)进程。master进程读入命令,进行一些初始化,然后fork并监控child进程。child进程分配若干线程进行工作,主要包 括一些管理线程和很多woker线程。
7 使用perftools分析jvm的native memory分配情况,通过perftools得到分析结果,没有发现问题。
8 又回到Java代码,发现代码中使用mina的一个类进行发送和序列化发送数据,使用的是direct buffer,于是改为使用jvm heap来存放数据。
9 按照这个思路,把所有可能出问题的direct buffer转变成Haep堆内存泄漏,如果这个代码有问题,必然会产生jvm heap暴涨。
10 修改代码后运行,果然内存暴涨,gc频繁,分析一下堆,发现堆空间都被socketsessionimpl的writerequestqueue队列所持有,这个对了是mina的写队列,也就是mina不能及时发送数据,导致堵在了这个队列里,所以还是mina导致了direct memory 泄漏。