JVM初探

JVM作为Java程序的容器,是所有Java程序依赖的平台。涉猎JVM的原理有助于Java Developer避开开发过程中的许多陷阱。

类加载

Java程序的基本单元是一个个类(Class)及类实例(Class Instance),在使用类之前,必须先将类加载进JVM的内存中。
当发生以下情况时,相关的类如果之前没有被加载,此时将被加载到JVM内存中:

  1. 创建类实例,这包括使用new关键字以及使用Class.forName反射方法来创建实例。
  2. 调用类的静态方法。
  3. 给类的静态变量赋值。
  4. 访问非final的静态变量。因为常量在被写进类的字节码之前,会被编译器优化为value而不是field,因此编译器并不会生成字节码来从实例中载入field的值,而是直接把这个value插入到字节码中。
  5. 初始化类时,若其父类尚未加载,则先初始化其父类。这种情形会递归加载所有之前未被加载的父类。
  6. JVM启动时,用户指定的包含main方法的类。
    而当发生以下情况时,类不会被加载:
  7. 子类调用父类的静态方法,子类不会被加载,只有父类被初始化。对于静态字段,只有直接定义这个字段的类才会被初始化。
  8. 作为数组元素被引用的类。
  9. 访问类的final静态变量,即常量。

过程

  1. Load:加载类到内存,在堆区建立一个Class对象。
    1. 通过类的全限定名获取该类的二进制字节流。
    2. 将该字节流代表的静态存储结构转换为运行时的数据结构。
    3. 在静态区生成java.lang.Class对象。
  2. Link:把Class的二进制数据合并到JRE,具体包括验证、准备及解析三步。
    • 验证:Class文件中类结构、语义、操作等的合法性。
    • 准备:为类的静态变量分配内存,并将非final的静态变量初始化默认值(零值),final的静态变量初始化为其指定值。
    • 解析:将常量池中的符号引用替换成直接引用。符号引用指向的目标不一定加载到了内存中,而直接引用指向的目标一定已经加载到了内存中。
  3. Initialization:赋予静态变量程序员指定的值,初始化静态代码块。

类加载器

  • Bootstrap ClassLoader
    1. $JAVA_HOME/lib中的类库。
    2. JVM的-Xbootclasspath参数指定的类库。
  • Extension ClassLoader
    1. $JAVA_HOME/lib/ext中的类库。
    2. java.ext.dirs系统变量指定的类库。
  • Application ClassLoader
    1. 加载Classpath下的类库。

JVM内存划分

JVM大体将其掌控的内存划分为下述几个区域:

  1. 程序计数器 Program Counter Register
  2. 虚拟机栈 JVM Stack
  3. 本地方法栈 Native Method Stack
  4. 堆 Heap
  5. 方法区 Method Area

程序计数器 Program Counter Register

  1. 当前线程的字节码行号指示器,指示执行哪条指令,分支、循环、跳转、异常等情况均依赖其指示来实现。
  2. 由于Java多线程由时间片轮转实现,因此每个线程私有程序计数器,以便切换后线程内的代码能恢复执行。

虚拟机栈 JVM Stack

  1. 考虑到可能的多线程环境,虚拟机栈是每个线程私有的。
  2. 方法执行时创建栈帧,压入虚拟机栈,返回时弹栈。每个栈帧包含局部变量表、操作数栈以及帧数据。栈帧中只保存原生数据类型和对象的引用。

Local Variables 局部变量表

局部变量表是一个以一个字长为单位、从0开始计数的数组。short、byte、char和boolean等类型的变量值在存入数组前要被转换成int值,而long和double在数组中占据连续的两项,在访问局部变量中的long或double时,只需取出连续两项的第一项的索引值即可,如某个long值在局部变量区中占据的索引为3、4项,取值时,指令只需取索引为3的long值即可。

Operand Stack 操作数栈

结构与局部变量表一致,但对它的访问是通过push和pop操作来进行的,而不是通过索引。操作数栈可以看成是栈帧所对应的方法的计算过程中,数据的临时存储区域。

Frame Data 帧数据

这个区域的作用主要有以下几个:

  • 解析方法内引用的常量池中的数据。
  • 保存Return Address,用于方法执行完后返回,恢复调用方的现场。
  • 保存异常表,用于方法执行过程中抛出异常时的异常处理,当出现异常时虚拟机查找相应的异常表看是否有对应的catch语句,如果没有就抛出异常终止这个方法调用。

本地方法栈 Native Method Stack

与虚拟机栈作用类似,但是为执行本地方法服务。

堆 Heap

  1. JVM只有一个堆区,被所有线程共享,存储对象(包括数组,因为数组也是一种对象)。
  2. 分为新生代(Y)、老年代(O)以及永久区(P)。
    • 新生代=Eden(8/10) + From Survivor(1/10) + To Survivor(1/10),Eden空间不足时,存活的对象转移到Survivor,多个Survivor用于交换。
    • 老年代存放生命周期长的对象。
    • 永久区存放Class和Meta信息等,GC时一般不清理。
  3. JVM GC算法为分代收集,设计为频繁地收集新生代,较少地收集老年代,更地收集永久区。
  4. Minor GC
    • 收集新生代的垃圾,存活对象转移到了Survivor区,清空Eden。
    • 若Survivor区满,则将Survivor区内的存活对象转移到老年代。
    • 若老年代满,则抛出OutOfMemoryException。
  5. Major GC
    • 收集老年代的垃圾。
    • 在Major GC的过程中,至少进行了一次Minor GC。

方法区/静态区/PermGen/MetaSpace

  1. Java 7-为PermGen永久区,分配在JVM堆中,大小固定;Java 8+改为MetaSpace,分配在本地内存,大小可以动态调整。
  2. 所有线程共享。
  3. 包含所有的Class信息、static变量以及Meta信息,包含的都是整个程序中唯一的元素。
  4. 每一个加载的类型会在方法区中保存:
    • 类及其父类的全限定名 (java.lang.Object除外,它没有父类)。
    • 类的类型 (class or interface)。
    • 访问修饰符 (public / abstract / final)。
    • 实现的接口的全限定名的列表。
    • 常量池。
    • 字段信息。
    • 方法信息。
    • 除常量外的静态变量。
    • ClassLoader引用。
    • Class引用。
  5. 每一个字段会在方法区中保存:
    • 字段声明顺序。
    • 字段名。
    • 字段的类型。
    • 字段的修饰符(public, private , protected, static, final, volatile, transient)。
  6. 每一个方法会在方法区中保存:
    • 方法声明顺序。
    • 方法名。
    • 方法返回类型或void。
    • 参数信息。
    • 方法修饰符(public, private, protected, static, final, synchronized, native, abstract)。
    • 如果方法不是抽象方法或本地方法(Native Method),还会保存方法的字节码、本地变量表及操作数栈的大小以及异常表。

常量池 Constant Pool

  1. Java 6-位于方法区,Java 7+位于堆中。
  2. 每个原子类型和String都有自己的常量池。
  3. 类加载期间就把常量加载进了常量池。
  4. byte, short, int, long, char, boolean类型的常量池范围除boolean(true/false)、char[0-127]外全部为[-128, 127];float和double类型没有常量池。
  5. 原子类型的包装类和String作为方法参数时,是值传递。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    Integer i1 = new Integer(1);
    Integer i2 = new Integer(1);
    //i1 != i2, 位于堆中非常量池的不同内存
    int int3 = 1;
    //i1 == int3, i2 == int3,比较时会自动拆箱
    Integer i3 = -128;
    Integer i4 = -128;
    //i3 == i4, 指向常量池中同一内存(-128~127)
    //自动装箱,Integer i3 = Integer.valueOf(-128);
    //valueOf会返回常量池中的对象或新生成堆中对象,此处返回常量池对象
    Integer i5 = 128;
    Integer i6 = 128;
    //i5 != i6, 指向不在常量池中
    //自动装箱,Integer i5 = Integer.valueOf(128);
    //valueOf会返回常量池中的对象或新生成堆中对象,此处返回堆中对象
    Boolean b1 = true;
    Boolean b2 = true;
    //b1 == b2, 常量池
    Double d1 = 1.0;
    Double d2 = 1.0;
    //d1 != d2, double/float未实现常量池
    String s1 = new String("hello");
    String s2 = new String("hello");
    //s1 != s2, 位于堆中非常量池的不同内存
    String s3 = "hello";
    String s4 = "hello";
    //s3 == s4, 调用String.valueOf(),指向常量池中同一内存
    String hello = "hello", hel = "hel", lo = "lo";
    //hello == "hello", 指向常量池中同一内存
    //hello == "hel" + "lo", 编译器自动优化右边
    //hello != "hel" + lo, 编译器不会优化变量,过程相当于
    //new StringBuilder().append("hel").append(lo).toString();
    //StringBuilder::toString()中返回new String();
    //hello != hel + lo, 变量拼接不可预料无法优化
    //hello == (hel + lo).intern(),手动载入常量池

垃圾回收

JVM的垃圾回收机制包括在程序运行过程中标记垃圾对象,以及在GC过程中回收被标记的垃圾对象。

标记垃圾

计数法
  • 新建一个对象或有引用指向该对象时计数器+1。
  • 当一个对象引用超过生存期限或指向新对象时计数器-1。
  • 计数器对应的对象变为0时回收该对象。
  • 快,简单,但无法检测循环引用:
    1
    2
    3
    4
    5
    6
    7
    8
    class A{public B b;}
    class B{public A a;}
    public static void main(String[] args) {
    A a = new A();
    B b = new B();
    a.b = b;
    b.a = a;
    }
跟踪法/根搜索法
  • 以GC roots为起点形成引用链,每个对象为有向图的顶点,GC roots不可达的对象将被回收。
  • GC roots包括:
    • 虚拟机栈中引用的对象, e.g. User user = new User();
    • 静态属性引用的对象, e.g. private static User user = new User();
    • 常量引用的对象, e.g. private static final User user = new User();
    • 本地方法(native method)栈中引用的对象。
强引用 Strong Reference

当使用Object obj = new Object();将obj指向了new Object()在堆中创建的对象时,obj就是一个强引用。当obj = null;时,new Object()代表的对象不被任何引用所指向,该对象就没有强引用了。
在垃圾回收的过程中,就算内存不足,一个有强引用的对象也是不会被标记/回收的,此时JVM会抛出OutOfMemoryError。

软引用 Soft Reference

软引用的对象是指某个对象没有被强引用所指向(e.g. 前文中的obj = null之后new Object()代表的对象),而被软引用所指向。
在垃圾回收的过程中,如果内存仍然充足,则不回收只有软引用的对象,否则回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User {
Integer id;
String name;
}
public class Test {
public static void main(String[] args) {
User user = new User(); //对象创建, 且有强引用
user.id = 1;
user.name = "Luck";
SoftReference<User> softRef = new SoftReference<User>(user); //添加软引用
user = null; //移除强引用
User sameUser;
if(null != (sameUser = softRef.get())) {
//只有软引用的对象未被垃圾回收
System.out.println("recovered " + sameUser.name + "(" + sameUser.id + ") from SoftReference!");
} else {
//只有软引用的对象被垃圾回收,内存较为稀缺
System.out.println("user object GCed!");
}
}
}

弱引用 Weak Reference

与软引用类似,但是当GC发生时,不论内存是否充足,只有弱引用的对象都会被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
User user = new User(); //对象创建, 且有强引用
user.id = 1;
user.name = "Luck";
WeakReference<User> weakRef = new WeakReference<User>(user); //添加软引用
user = null; //移除强引用
User sameUser;
if(null != (sameUser = weakRef.get())) {
//只有弱引用的对象未被垃圾回收
System.out.println("recovered " + sameUser.name + "(" + sameUser.id + ") from WeakReference!");
} else {
//只有弱引用的对象被垃圾回收
System.out.println("user object GCed!");
}
}

虚引用 Phantom Reference

虚引用“形同虚设”,对象有没有虚引用与对象会不会被GC没有关系。虚引用(PhantomReference)必须和引用队列(ReferenceQueue)联合使用,通常用来跟踪对象垃圾回收的活动。

回收垃圾

标记-清除算法 Mark-Sweep
  • 首先遍历有向图,标记所有可达的对象,然后扫描堆栈回收未被标记的对象。
  • 效率低,产生大量不连续空间。
标记-压缩算法 Mark-Compact
  • 第一阶段与标记-清除算法相同,第二阶段把被标记的对象移动到内存的一端,从而形成连续内存,并回收边界以外的内存。
  • 提高了内存利用率,可用于老年代。
标记-复制算法 Copying
  • 将内存分为等大小的两块,每次只使用其中一块,GC时把存活的对象复制到另一块,把前一块的内存回收。
  • 简单高效,内存利用率较低,可用于新生代(8:1:1)的GC。
分代收集算法 Generational Collection
  • 新生代采用标记-复制算法。
  • 老年代采用标记-压缩算法。

内存泄露

对于JVM内存的控制,虽然有较为科学的防范措施(垃圾回收),但有时仍免不了再也访问不到的对象占据内存空间,降低了内存的使用效率。以下是几种常见的内存“泄露”场景。

  1. 静态集合中的元素失去外部引用:

    1
    2
    3
    4
    5
    static HashMap<String, Object> map;
    Object o = new Object();
    map.put("o", o);
    o = null; //集合仍然持有该对象的引用,不能被GC
    System.out.println(null == map.get("o")); //false
  2. 集合中的元素被修改后,remove原引用失效:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    Set<Person> set = new HashSet<Person>();
    Person p1 = new Person();
    Person p2 = new Person();
    set.add(p1);
    set.add(p2);
    p2.setAge(2);
    set.remove(p2); //失败
    System.out.println(set.size()); //2

    class Person {
    private int age;
    public void setAge(int age) {
    this.age = age;
    }
    @Override
    public int hashCode() {
    //Person类需实现hashCode方法,且hashCode方法的返回值与age属性相关
    return super.hashCode()+age;
    }
    }
  3. 监听器/各种连接:

    • 调用addListener()后忘记释放。
    • 建立DB Connection,Socket Connection,I/O Stream后忘记调用相应的close()方法。
    • 使用DB连接时不仅需要调用Connection::close(),还需要关闭ResultSet和Statement。
  4. 单例对象HAS-A另外一个对象的引用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class A{...}
    public class B{
    private B(){}
    private static B INSTANCE = new B();
    public static B getInstance(){
    return INSTANCE;
    }
    private A a;
    public void setA(A a){this.a = a;}
    //对象a不能被垃圾回收
    }