运行时内存区域划分
堆
JVM中堆是存放对象的主要区域(方法区中存放了Class对象),堆一般分为新生代和老年代,新生代又分为一个Eden区和两个Survivor区,这部分区域是线程共有的。
栈
栈有两种一种是虚拟机栈(执行Java方法),一种是本地方法栈(执行Native方法),栈是线程私有的,当一个方法运行,就会在这个线程的栈上创建一个栈帧,栈帧中包含局部变量表、操作数栈等,方法结束,栈帧也就销毁了。
方法区
我们把Class文件加载进来之后,会把Class的信息(类变量、常量、方法的指令等)放在方法区,并且在方法区创建一个这个类的引用Class对象。方法区是线程共有的。
1.8之前方法区的实现被称为永久代,JVM启动之后会设置永久代的最大大小,所以如果频繁使用反射等读入字节码的技术,就很容易出现方法区OOM的情况;所以在1.8之后方法区的实现改为用元空间MetaSpace。元空间使用的就是计算机的内存(和直接内存一样),所以元空间大小受限于操作系统的可以用内存大小,这个时候再发生OOM,那应该是操作系统内存不够了。
运行时常量池、静态常量池、字符串常量池
在方法区中还有一个运行时常量池,这里对另外两个常量池进行一个综合的对比。
- 静态常量池
在Class文件中,有一个常量池,里面记录了这个类的符号引用、常量等内容,因为是存在Class文件中,我把他成为静态常量池。
- 运行时常量池
当我们把Class文件加载进内存后,静态常量池的内容会放入运行时常量池。
- 字符串常量池
JVM为了优化,会把字符串常量放入字符串常量池中,这部分在堆内。
直接内存
我理解直接内存不是Java进程的内存,就是内核态的内存(page cache),不会被GC管理。Java中可以DirectByteBuffer来使用直接内存,可以通过内存映射mmap的方式实现零拷贝,提高IO效率。
程序计数器
内存管理
内存管理部分关注的就是对象在堆中怎么分配、怎么访问、怎么删除。
为什么说栈内存的使用效率比堆内存高?我认为这个就可以从内存管理的这三个角度回答。
对象怎么分配
一般来说,当遇到new指令的时候,就会开始对象的分配,这时候的步骤是这样的:
- 遇到new指令,先会看这个类有没有完成类加载,如果没有先走类加载机制那套;
- 为对象在堆上分配内存;
- 为对象的实例变量(非static修饰的变量)赋予初值;
- 执行对象的构造方法。
这个过程也被称为对象的实例化。
如何分配内存
这里为对象分配内存,其实是有很多学问的,有以下这些情况:
- 一般来说,对象是分配在Eden区,新生代采用复制算法,所以不会有内存碎片,这时候分配内存的算法称为指针碰撞,也就是挪动一下指针,挪动的这部分就是分配给对象的内存。
- 因为Eden区是线程共有的,所以这时候分配内存需要考虑线程竞争问题,JVM是采用CAS和重试来分配的;
- JVM还有一种优化的方案,为每个线程预先分配一块本地线程缓冲区,这样对象分配现在这个线程私有的空间分配。
- 如果对象是个大对象,那么按照分配的原则,大对象直接会分配在老年代,老年代如果是用标记-清除算法,会有内存碎片,那分配算法要用空闲列表。
- 此外还有一种优化的手段,如果逃逸分析,分析了这个对象不会被别的方法访问,那其实可以直接分配在栈上。
实例变量、类变量、局部变量
类变量:static修饰的类中的变量,在类加载的时候就会赋值。在类加载的准备阶段会为类变量赋零值(如果是final修饰的常量,直接完成赋值),在初始化阶段执行类构造器,完成赋值。保存在方法区中。
实例变量:类中非static修饰的变量,在实例化的时候赋值。在分配内存之后会为实例变量赋初值,之后执行构造方法的时候完成赋值。保存在堆中。
局部变量:方法中定义的变量,没有零值。对象怎么访问
在栈上使用对象,是需要通过对象在栈上的引用来访问的,这里有两种访问方式:
访问方式 | 优点 | 缺点 |
---|---|---|
在堆上分配一个句柄池,栈上引用指向句柄池,句柄池引用再指向堆中对象的内存地址 | 对象内存地址变了(发生GC),不需要栈上引用修改 | 多了一次寻址,访问速度慢 |
直接访问,栈上引用直接指向堆中对象的内存地址 | 访问速度快 | 对象内存地址变了(发生GC),需要栈上引用修改 |
对象怎么删除
一句话来说,Java是通过垃圾回收来删除不用的对象的。但这里细说的话也可以有以下这些问题:
- 什么时候触发垃圾回收?
- 怎么判断对象要不要删除?
- 具体怎么回收(回收算法)?
-
什么时候触发垃圾回收
创建对象当Eden区分配不下的时候,会触发young GC;对象年龄达到15的会放入老年代,如果这时老年代分配不下的时候,会触发full GC。
怎么判断对象要不要删除
有两种算法:引用计数和可达性分析,引用计数的方法不能解决两个对象互相引用,但实际没用的情况,所以一般都用可达性分析。
可达性分析是说从GCRoot开始,沿着引用链去找,如果一个对象和引用链没有关联,那这个对象就是需要回收的。
那这里什么是GCRoot呢?其实很简单,我们要回收堆上不用的对象,那GCRoot就是static修饰的类变量或者栈上局部变量表中的变量。具体怎么回收(回收算法)
标记-清除算法。可达性分析把要回收的对象进行标记,然后把标记的对象回收了。缺点是,会产生内存碎片。
- 复制算法。把内存进行分块,然后把回收剩下的对象复制到空白的那块上。缺点是,浪费了内存。
- 标记-整理算法。在标记-清除的基础上,把剩下的对象进行一个整理。缺点是,性能不好。
- 分代回收算法。因为对象大多数都是朝生夕死的,所以可以分为老年代和新生代,新生代存储新的或者存活时间短的对象,老年代存储存活时间长的对象。
在分代回收的基础上,一般新生代都会采用复制算法,因为一次GC之后,大多数对象都被回收了,复制的效率高,并且存活下来的对象少,我们可以都放入一个survivor区。并且,因为有老年代的保障,我们复制算法即使放不下survivor,也可以通过分配担保的策略,来完成内存分配。老年代一般就采用标记-清除或标记-整理。
算法落地(垃圾回收器)
上面的这四个算法只能说是回收的理论思路,那落实到生产使用,我们也需要知道以下常用的垃圾回收器。
- 单线程垃圾回收器。老年代用标记-整理算法,新生代用复制算法。
- 多线程垃圾回收器。老年代用标记-整理算法,新生代用复制算法。
- CMS。只能工作在老年代,用标记-清除算法,回收过程分为初始标记(只标记GCRoot);并发标记(和用户线程一起工作,沿着GCRoot标记);重新标记(标记并发标记修改的对象);并发清除(和用户线程一起工作)。注重停顿时间,STW时间短,但是存在3个问题:产生内存碎片、产生浮动垃圾(需要提前GC)、对CPU不友好。
- G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。
1.8默认使用的是:新生代注重吞吐量的并行垃圾回收器;老年代单线程垃圾回收器
类加载机制
加载
什么时候加载
- 遇到new指令;
- 反射;
-
加载做的事情
加载阶段做的事情就是,根据类的全限定名,把二进制文件(不一定都是Class文件)加载到方法区,并且在方法区创建一个Class对象,作为这个类的访问入口。
具体加载的工具(类加载器)
类加载器就是用来完成加载动作的,然后需要记住两点:
类加载器一个很大的特点是双亲委派机制。也就是说类加载器加载一个类,不会直接去进行加载,而是先委托父类去加载,只有父类不能加载的时候才自己去加载。
- 确定一个类,除了类本身之外,类加载器不同,这个类也不同。
一般说的类加载器有三种:启动类加载器(加载rt.jar包下的类);扩展类加载器(加载扩展包下的类);应用类加载器(加载项目中定义的类)
准备
准备阶段会为类变量赋初值,如果有常量,包括final static修饰的变量,会直接赋值。
初始化
初始化阶段会收集类中的static代码块,类变量的赋值动作等,然后放到类构造方法中执行。
编译器
前端编译器
javac前端编译器的作用就是把Java文件编译成Class文件,并且支持Java语法糖。
javac并没有做什么性能上的优化,但是会对Java语法糖进行解析,例如拆箱装箱、foreach循环、泛型擦除。
即时编译器
Java语言一般是这样工作的:
- Java代码经过前端编译器编译成字节码文件Class文件;
- 程序执行的时候,解释器一条一条的解释字节码然后执行;
- 即时编译器会把热点代码编译成本地机器码,加快执行效率。
此外即时编译器还会有一些优化手段:
- 方法内联。减少方法调用,减少栈帧创建。
- 逃逸分析。变量会被别的方法访问到,方法逃逸(没有方法逃逸的可以栈上分配对象);变量会被别的线程访问到,线程逃逸(没有线程逃逸的,可以去掉锁)。
- 数据边境检查消除。
- 公共子表达式消除。
碰到的OOM场景
之前参加的中间件性能挑战赛,碰到了一个OOM的问题,大概的场景是这样的:有很多很多消息,消息从属于100W个队列,然后去实现这些消息的存储以及顺序读和随机读。
我的方案:我在内存中先缓存2000W条消息,也就是100W个队列,每个队列20条消息,然后再一次写到磁盘上。具体的数据结构就是:用一个Map存储,key是队列名,value是List,List中是10个MsgObject,MsgObject有两个字段,队列名和消息内容bate数组。
产生OOM的原因:对字符串常量池的理解不对,MsgObject中的队列名,我用常量“queue-” + 变量i 的方式得到的,我以为这种方式会复用队列名字符串,但是后面dump了堆栈信息之后,发现队列名都是重复的。
解决方式:把100W个队列名缓存起来了,MsgObject中就引用缓存中的队列名。