运行时内存区域划分

JVM中堆是存放对象的主要区域(方法区中存放了Class对象),堆一般分为新生代和老年代,新生代又分为一个Eden区和两个Survivor区,这部分区域是线程共有的。

栈有两种一种是虚拟机栈(执行Java方法),一种是本地方法栈(执行Native方法),栈是线程私有的,当一个方法运行,就会在这个线程的栈上创建一个栈帧,栈帧中包含局部变量表、操作数栈等,方法结束,栈帧也就销毁了。

方法区

我们把Class文件加载进来之后,会把Class的信息(类变量、常量、方法的指令等)放在方法区,并且在方法区创建一个这个类的引用Class对象。方法区是线程共有的。
1.8之前方法区的实现被称为永久代,JVM启动之后会设置永久代的最大大小,所以如果频繁使用反射等读入字节码的技术,就很容易出现方法区OOM的情况;所以在1.8之后方法区的实现改为用元空间MetaSpace。元空间使用的就是计算机的内存(和直接内存一样),所以元空间大小受限于操作系统的可以用内存大小,这个时候再发生OOM,那应该是操作系统内存不够了。

运行时常量池、静态常量池、字符串常量池

在方法区中还有一个运行时常量池,这里对另外两个常量池进行一个综合的对比。

  1. 静态常量池

在Class文件中,有一个常量池,里面记录了这个类的符号引用、常量等内容,因为是存在Class文件中,我把他成为静态常量池。

  1. 运行时常量池

当我们把Class文件加载进内存后,静态常量池的内容会放入运行时常量池。

  1. 字符串常量池

JVM为了优化,会把字符串常量放入字符串常量池中,这部分在堆内。

直接内存

我理解直接内存不是Java进程的内存,就是内核态的内存(page cache),不会被GC管理。Java中可以DirectByteBuffer来使用直接内存,可以通过内存映射mmap的方式实现零拷贝,提高IO效率。

程序计数器

线程私有的,标示程序运行的行数。

内存管理

内存管理部分关注的就是对象在堆中怎么分配、怎么访问、怎么删除。

为什么说栈内存的使用效率比堆内存高?我认为这个就可以从内存管理的这三个角度回答。

对象怎么分配

一般来说,当遇到new指令的时候,就会开始对象的分配,这时候的步骤是这样的:

  1. 遇到new指令,先会看这个类有没有完成类加载,如果没有先走类加载机制那套;
  2. 为对象在堆上分配内存;
  3. 为对象的实例变量(非static修饰的变量)赋予初值;
  4. 执行对象的构造方法。

    这个过程也被称为对象的实例化。

如何分配内存

这里为对象分配内存,其实是有很多学问的,有以下这些情况:

  1. 一般来说,对象是分配在Eden区,新生代采用复制算法,所以不会有内存碎片,这时候分配内存的算法称为指针碰撞,也就是挪动一下指针,挪动的这部分就是分配给对象的内存。
    • 因为Eden区是线程共有的,所以这时候分配内存需要考虑线程竞争问题,JVM是采用CAS和重试来分配的;
    • JVM还有一种优化的方案,为每个线程预先分配一块本地线程缓冲区,这样对象分配现在这个线程私有的空间分配。
  2. 如果对象是个大对象,那么按照分配的原则,大对象直接会分配在老年代,老年代如果是用标记-清除算法,会有内存碎片,那分配算法要用空闲列表。
  3. 此外还有一种优化的手段,如果逃逸分析,分析了这个对象不会被别的方法访问,那其实可以直接分配在栈上。

    实例变量、类变量、局部变量

    类变量:static修饰的类中的变量,在类加载的时候就会赋值。在类加载的准备阶段会为类变量赋零值(如果是final修饰的常量,直接完成赋值),在初始化阶段执行类构造器,完成赋值。保存在方法区中。
    实例变量:类中非static修饰的变量,在实例化的时候赋值。在分配内存之后会为实例变量赋初值,之后执行构造方法的时候完成赋值。保存在堆中。
    局部变量:方法中定义的变量,没有零值。

    对象怎么访问

    在栈上使用对象,是需要通过对象在栈上的引用来访问的,这里有两种访问方式:
访问方式 优点 缺点
在堆上分配一个句柄池,栈上引用指向句柄池,句柄池引用再指向堆中对象的内存地址 对象内存地址变了(发生GC),不需要栈上引用修改 多了一次寻址,访问速度慢
直接访问,栈上引用直接指向堆中对象的内存地址 访问速度快 对象内存地址变了(发生GC),需要栈上引用修改

对象怎么删除

一句话来说,Java是通过垃圾回收来删除不用的对象的。但这里细说的话也可以有以下这些问题:

  1. 什么时候触发垃圾回收?
  2. 怎么判断对象要不要删除?
  3. 具体怎么回收(回收算法)?
  4. 算法落地有哪些垃圾回收器?

    什么时候触发垃圾回收

    创建对象当Eden区分配不下的时候,会触发young GC;对象年龄达到15的会放入老年代,如果这时老年代分配不下的时候,会触发full GC。

    怎么判断对象要不要删除

    有两种算法:引用计数和可达性分析,引用计数的方法不能解决两个对象互相引用,但实际没用的情况,所以一般都用可达性分析。
    可达性分析是说从GCRoot开始,沿着引用链去找,如果一个对象和引用链没有关联,那这个对象就是需要回收的。
    那这里什么是GCRoot呢?其实很简单,我们要回收堆上不用的对象,那GCRoot就是static修饰的类变量或者栈上局部变量表中的变量。

    具体怎么回收(回收算法)

  5. 标记-清除算法。可达性分析把要回收的对象进行标记,然后把标记的对象回收了。缺点是,会产生内存碎片。

  6. 复制算法。把内存进行分块,然后把回收剩下的对象复制到空白的那块上。缺点是,浪费了内存。
  7. 标记-整理算法。在标记-清除的基础上,把剩下的对象进行一个整理。缺点是,性能不好。
  8. 分代回收算法。因为对象大多数都是朝生夕死的,所以可以分为老年代和新生代,新生代存储新的或者存活时间短的对象,老年代存储存活时间长的对象。

在分代回收的基础上,一般新生代都会采用复制算法,因为一次GC之后,大多数对象都被回收了,复制的效率高,并且存活下来的对象少,我们可以都放入一个survivor区。并且,因为有老年代的保障,我们复制算法即使放不下survivor,也可以通过分配担保的策略,来完成内存分配。老年代一般就采用标记-清除或标记-整理。

算法落地(垃圾回收器)

上面的这四个算法只能说是回收的理论思路,那落实到生产使用,我们也需要知道以下常用的垃圾回收器。

  1. 单线程垃圾回收器。老年代用标记-整理算法,新生代用复制算法。
  2. 多线程垃圾回收器。老年代用标记-整理算法,新生代用复制算法。
  3. CMS。只能工作在老年代,用标记-清除算法,回收过程分为初始标记(只标记GCRoot);并发标记(和用户线程一起工作,沿着GCRoot标记);重新标记(标记并发标记修改的对象);并发清除(和用户线程一起工作)。注重停顿时间,STW时间短,但是存在3个问题:产生内存碎片、产生浮动垃圾(需要提前GC)、对CPU不友好。
  4. G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。

    1.8默认使用的是:新生代注重吞吐量的并行垃圾回收器;老年代单线程垃圾回收器

类加载机制

类加载机制分为5个步骤:加载、验证、解析、准备、初始化。

加载

什么时候加载

  1. 遇到new指令;
  2. 反射;
  3. 加载一个类,如果其父类还没有被加载的话先加载父类。

    加载做的事情

    加载阶段做的事情就是,根据类的全限定名,把二进制文件(不一定都是Class文件)加载到方法区,并且在方法区创建一个Class对象,作为这个类的访问入口。

    具体加载的工具(类加载器)

    类加载器就是用来完成加载动作的,然后需要记住两点:

  4. 类加载器一个很大的特点是双亲委派机制。也就是说类加载器加载一个类,不会直接去进行加载,而是先委托父类去加载,只有父类不能加载的时候才自己去加载。

  5. 确定一个类,除了类本身之外,类加载器不同,这个类也不同。

    一般说的类加载器有三种:启动类加载器(加载rt.jar包下的类);扩展类加载器(加载扩展包下的类);应用类加载器(加载项目中定义的类)

准备

准备阶段会为类变量赋初值,如果有常量,包括final static修饰的变量,会直接赋值。

初始化

初始化阶段会收集类中的static代码块,类变量的赋值动作等,然后放到类构造方法中执行。

编译器

前端编译器

javac前端编译器的作用就是把Java文件编译成Class文件,并且支持Java语法糖。

javac并没有做什么性能上的优化,但是会对Java语法糖进行解析,例如拆箱装箱、foreach循环、泛型擦除。

即时编译器

Java语言一般是这样工作的:

  1. Java代码经过前端编译器编译成字节码文件Class文件;
  2. 程序执行的时候,解释器一条一条的解释字节码然后执行;
  3. 即时编译器会把热点代码编译成本地机器码,加快执行效率。

此外即时编译器还会有一些优化手段:

  1. 方法内联。减少方法调用,减少栈帧创建。
  2. 逃逸分析。变量会被别的方法访问到,方法逃逸(没有方法逃逸的可以栈上分配对象);变量会被别的线程访问到,线程逃逸(没有线程逃逸的,可以去掉锁)。
  3. 数据边境检查消除。
  4. 公共子表达式消除。

    碰到的OOM场景

    之前参加的中间件性能挑战赛,碰到了一个OOM的问题,大概的场景是这样的:有很多很多消息,消息从属于100W个队列,然后去实现这些消息的存储以及顺序读和随机读。
    我的方案:我在内存中先缓存2000W条消息,也就是100W个队列,每个队列20条消息,然后再一次写到磁盘上。具体的数据结构就是:用一个Map存储,key是队列名,value是List,List中是10个MsgObject,MsgObject有两个字段,队列名和消息内容bate数组。
    产生OOM的原因:对字符串常量池的理解不对,MsgObject中的队列名,我用常量“queue-” + 变量i 的方式得到的,我以为这种方式会复用队列名字符串,但是后面dump了堆栈信息之后,发现队列名都是重复的。
    解决方式:把100W个队列名缓存起来了,MsgObject中就引用缓存中的队列名。