Earth Guardian

You are not LATE!You are not EARLY!

0%

JVM:深入理解Java虚拟机--读书笔记

在Java程序运行时环境中,JVM需要对两类线程共享的数据进行协调:
1)保存在堆中的实例变量
2)保存在方法区中的类变量

这两类数据是被所有线程共享的。

死锁

产生死锁 4 个必要条件

  • 互斥条件
    一个资源每次只能被一个进程使用
  • 保持和等待条件
    一个进程因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件
    进程已获得的资源,在未使用完之前,不能强行剥夺
  • 环路等待条件
    若干进程之间形成一种头尾相接的循环等待资源关系

示例

解除死锁的方法

破坏 4 个条件同时成立:
A.进程互斥,m<nw时可能会出现死锁,其中m表示资源总数,n表示互斥进程数,w表示每个进程需要的资源数
B.在m<nw时,避免死锁的方法,先均匀分配1个资源,剩下的全都分给某一个进程,确保这个进程能获取足够多的资源完成并退出。
C. 也就是说B中是理想避免死锁的方法,但还是可能出现死锁,比如剩下部分并没有全部分给单个进程,而是再次均匀分配就出现死锁了。
D. 当两个进程都在请求资源,但是没有资源可用时,则称这两个进程为阻塞节点,资源图称为是不可以简化的、是死锁的。

volatile 关键字

synchronize 关键字

synchronized的字节码表示:synchronized关键字及实现细节
Java 语言中存在两种内建的 synchronized 语法:

  • synchronized 语句
    对于 synchronized 语句当 Java 源代码被 javac 编译成 bytecode 的时候,会在同步块的入口位置和退出位置分别插入 monitorentermonitorexit 字节码指令
  • synchronized 方法
    synchronized 方法则会被翻译成普通的方法调用和返回指令。如: invokevirtual、areturn 指令,在 JVM 字节码层面并没有任何特别的指令来实现被 synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置 1 ,表示该方法是同步方法并使用调用该方法的对象或该方法所属的 ClassJVM 的内部对象表示 Klass 做为锁对象。

并发主要关注那些问题

线程安全,内存管理及垃圾回收,java并发

JVM 内存模型及多线程并发

原子性,可见性,有序性

Java 运行时数据区域

介绍

0006-jvm-java-virtual-machine-jvm-runtime-model.png

程序计数器、虚拟机栈,本地方法栈的生命周期都和当前线程保持一致,这三块区域属于当前线程私有的。而方法区、堆、常量池是所有线程共享的数据区

程序计数器 The pc Register

Java 虚拟机栈 Java Virtual Machine Stacks

每个方法在执行时都会创建一个栈帧存储:局部变量表、操作数栈、动态链接、方法返回地址等。常说的 Java 内存分为堆内存 Heap 和栈内存 Stack,这里的栈指的就是虚拟机栈,或者说是虚拟机栈中的局部变量表部分。

  • 局部变量表
    存放了编译期可知的各种基本数据类型,对象引用(reference 类型)和 returnAddress 类型(指向一条字节码指令的地址)
  • 基本数据类型
    boolean, byte, char, short, int, float, long, double
  • 存储空间
    其中 64 位长度的 longdouble 类型的数据会占用 2 个局部变量空间,其余的数据类型只占用 1 个(比如 32 位虚拟机,一个变量空间占用32位)。根据 longdouble 的非原子性协议,把这两个数据类型分割为两次 32 位读写操作(即需求读取两次)。不过局部变量是线程私有数据,即使两次读写也不会引起数据安全问题。

本地方法栈 Native Method Stacks

Heap

所有对象的实例及数组都是在堆上分配的。

方法区 Method Area

别名:Non Heap

运行时常量池 Run-Time Constant Pool

异常

  • StackOverFlowError
    如果现场请求的栈深度大于虚拟机所允许的深度,将抛出该异常
    虚拟机栈和本地方法栈区域可能会抛出该异常

  • OutOfMemoryError
    如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,将抛出该异常
    只有程序计数器中不会抛出 OutOfMemory,其他数据区域都可能会抛出

引用

Java 引用分为:强引用、软引用、弱引用、虚引用。这 4 种引用强度一次逐渐减弱。垃圾回收时会根据引用类型来决定是否回收这些内存

  • 强引用 Strong Reference
    最常见的应用: Object obj = new Object(),这类引用就是强引用,也就是我们一般声明对象是时虚拟机生成的引用。垃圾回收时需要严格判断当前对象是否被强引用,只要强引用还存在,则不会被垃圾回收。

  • 软引用 Soft Reference
    使用 SoftReference 类来实现软引用,用来描述有用但并非必需的对象。对于软引用关联着的对象在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间;如果剩余内存相对富裕,则不会进行回收。在系统要发生内存溢出异常之前,会将这些对象进行回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。换句话说,虚拟机在发生 OutOfMemory 时,肯定是没有软引用存在的。

  • 弱引用 Weak Reference
    使用 WeakReference 类来实现弱引用,用来描述非必需对象,但是强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾回收发生时,无论当前内存是否足够,被弱引用关联的对象都会被回收,因此其生命周期只存在于一个垃圾回收周期内。

  • 虚引用 Phantom Reference
    使用 PhantomReference 类来实现虚引用,唯一目的是对象在被回收时能收到一个系统通知。也称为幽灵引用或者幻影引用,是最弱的一种引用关系。虚引用完全不会影响对象的生存时间,也无法通过虚引用来取得对象的实例

  • 总结
    软引用:当虚拟机内存不足时,会回收它指向的对象
    弱引用:随时可能会被垃圾回收器回收,不一定要等到虚拟机内存不足时才强制回收
    软引用多用作来实现缓存机制(cache),而弱引用一般用来防止内存泄漏,要保证内存被虚拟机回收

类加载

Class 文件的魔数

魔数为: 0xCAFEBABE,即 Class 文件的前 4 个字节固定为这串数字

65536 方法数

常量池包含 14 种常量类型(每个都有各自的数据结构),其中包含一个 CONSANT_Utf8_info 类型的常量: u1 type; u2 length; u1 bytesClass 文件中的方法,字段等都是该常量来描述的,所有该常量的最大长度也就是 Java 中方法、字段名的最大长度。length 的最大值即为 u2 类型能表达的最大值 65536 。所以 Java 程序如果定义了超过 64KB 英文字符的变量、方法名、方法数等,将无法编译

类加载前的初始化

有且只有 5 种情况必须对类进行初始化

  • 遇到 new, getstatic, putstatic, invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这几条指令的场景为:
    使用 new 关键字实例化对象;读取或设置类的静态字段(但是被 final 或者进入常量池的静态字段除外);调用一个类的静态方法
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,需要先触发初始化
  • 初始化一个类的时候,如果发现父类还没有初始化,先触发父类初始化
  • 用户指定要执行额主类:即包含 main 主类的先初始化
  • 如果 java.lang.invoke.MethodHandle 实例最后解析的结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,如果没有初始化会触发其初始化

类变量

类中被 static 修饰的变量,会被赋值两次。

  • 类加载准备阶段
    准备阶段赋值为系统初始值,如: int=0;reference=null
  • 类加载初始化阶段
    初始化阶段赋值为执行 Java 语句的赋值,即类变量真实的值

类加载器

类的加载器的 3 种分类

  • 启动类加载器 Bootstrap ClassLoader
    这个是 C++ 实现,虚拟机的一部分,负责将 JAVA_HOME\lib 目录下的类库加载到虚拟机内存中,无法被用户直接使用
  • 扩展类加载器 Extension ClassLoader
    sun.misc.Launcher$ExtClassLoader 实现,负责加载 JAVA_HOME\lib\ext 中的类库,用户可以直接使用这些扩展类加载器
  • 应用程序类加载器 Application ClassLoader
    sun.misc.Launcher$AppClassLoader 实现,负责加载 CLASSPATH 中的类库。这个类加载器是 ClassLoader.getSystemClassLoader() 的返回值,所以一般称为系统类加载器,用户可以直接使用

双亲委派模型 Parents Delegation Model

0006-jvm-java-virtual-machine-parents-delegation-model.jpg

上图所示的类加载器之间的层次关系,称为类加载器的双亲委派模型。该模型要求除了顶层启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的父子关系不是继承关系,而是组合关系来复用父类代码。
双亲委派模型原则:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

方法调用:分派

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(具体调用哪个方法)。Class 文件的编译过程中不包含链接步骤,所有的方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。方法调用在类加载期间,甚至运行期间才能确定目标方法的入口地址(直接引用)。

变量静态类型和实际类型

先看一段代码:Human man = new Man()

  • 静态类型 Static Type
    其中 Human 称为变量的静态类型,或者叫外观类型 Apparent Type。静态类型仅仅在使用时有可能会出现改变,但在编译期能够明确最终的静态类型
  • 实际类型 Actual Type
    其中 Man 称为变量的实际类型。实际类型在运行期才能确定,编译器无法判定对象的实际类型是什么
  • 示例
    1
    2
    3
    4
    5
    6
    // 实际类型变化,运行时才能确定  
    Human man = new Man();
    man = new Woman();
    // 静态类型变化,使用时就确定了
    sr.sayHello((Man) man);
    sr.sayHello((Woman) man);
  • 引申
    根据内存模型,man 是存储在虚拟栈中,属于局部变量表中的引用变量;而 new Man() 表示在堆中分配一块存储区,引用变量的值为指向这块堆的指针

重载与重写及分派

  • 重载
    编译器在编译阶段通过参数的静态类型来作为重载的判断依据:
1
2
3
4
5
public void sayHello(Human guy){...}
public void syaHello(Man guy){...}
// 根据静态类型的特点,调用的是 sayHello(Human)
Human man = new Man();
sayHello(man);
  • 重写
    只有在运行时根据实际类型,虚拟机才能确定调用哪个重写的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
class Man extends Human{
@Override
void sayHello(){...}
}
class Woman extends Human{
@Override
void sayHello(){...}
}
Human man = new Man();
Human woman = new Woman();
// 根据实际类型的特点,调用子类的方法
man.sayHello();
woman.sayHello();
  • 静态分派
    依赖静态类型来定位方法具体执行哪个版本(重载时),这个分派动作称为静态分派。典型应用:重载。在静态分派过程中,如果没有指定显示的静态类型,会发生类型的自动转换来匹配最可能的类型, 如:sayHello('a'),如果没有明确重载 sayHello(char c) 方法,会按照 :char -> int -> long -> float -> double ->Character ->Serializable --> Object 的顺序转型。其中:
  • char 转为 int: 表示 a 除了代表字符串,还可以代表数字 97
  • char 转为它的封装类型 Character :是一次自动装箱过程
  • char 转为 Serializable 是因为 Character 实现了序列化可比较
    但是如果同时重载了 sayHello(Serializable s)sayHello(Comparable c) 会提示类型模糊,编译报错
  • 动态分派
    在运行期根据实际类型确定方法执行哪个版本(重写),这个分派过程称为动态分派

泛型

本质是参数化类型 Parametersized Type 的应用,也就是说操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别被称为泛型类、泛型接口和泛型方法。
Java 的泛型只在源码中存在,在编译后的字节码文件中都会被替换为原生类型 Raw Type,并且在相应的地方插入了强制转型代码。 Java 语言泛型的实现方法称为类型擦除Type Erasure),这种实现也被称为伪泛型。

1
2
3
4
5
6
7
8
// 源码
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "world");
System.out.println(map.get("hello"));
// 反编译结果:类型擦除后在使用时做强行转换
Map map = new HashMap();
map.put("hello", "world");
System.out.println((String) map.get("hello"));

特征签名

方法特征签名:仅仅包括方法名称、参数类型以及参数顺序。(不包含返回值)

Class 文件格式中方法表

包含的几个重要字段:名称、描述符、属性组等

  • 名称
    全限定名:比如 com/google/dagger/DaggerClass; 是这个类的全限定名,仅仅是把类全名的 . 替换为 / 而已,再以分号结束
    简单名称:指没有类型和参数修饰的方法或者字段名称,比如这个类中的 inc() 方法和 m 字段,简单名称分别为 incm
    Class 文件格式方法表中的 name_index 指的就是简单名称

  • 描述符
    描述符的作用用来描述字段的数据类型、方法的参数列表(包含数量、类型和顺序)和返回值。根据描述符规则:基本数据类型和无返回值的 Void 类型都用首字母大写来表示;对象类型使用字符 L 加对象的全限定名来表示;数组类型每一个维度使用一个前置 [ 字符来描述。示例:

1
2
3
4
5
int[] --> [I
java.lang.String[][] --> [[Ljava/lang/String;
void inc() --> ()V
java.lang.String.toString() --> ()Ljava/lang/String;
int indexOf(char[] source, int offset, int Count) --> ([CII)I

只有描述符不一致的两个方法才能再同一个 Class 文件中共存

  • 属性组
    每个方法可以有任意个与之相关的属性(20 个)

    Sigature 属性:
    Java 语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量或者参数化类型,则 Signature 属性会记录泛型签名信息。所以类型擦除仅仅是对方法的字节码进行了擦除,实际上 Signature 属性保留了泛型信息

泛型重载

  • 源码
    方法 mytest 重载,参数类型都是泛型 List<E>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.List;
import java.util.ArrayList;

public class TestGeneric{
public static int mytest(List<Integer> s) {
return 0;
}

public static String mytest(List<String> s) {
return "";
}

public static void main(String[]agrs){
mytest(new ArrayList<String>());
mytest(new ArrayList<Integer>());
}
}
  • 编译
    根据特征签名的定义(不包含返回值),以及泛型的类型擦除特性,mytest 特征签名是一样的,所以在编译过程中报错:
    name clash: mytest(List<String>) and mytest(List<Integer>) have the same erasure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// java 7及以上不能编译通过
xmt@server005:~/test/java$ java -version
java version "1.7.0_91"
OpenJDK Runtime Environment (IcedTea 2.6.3) (7u91-2.6.3-0ubuntu0.12.04.1)
OpenJDK 64-Bit Server VM (build 24.91-b01, mixed mode)
xmt@server005:~/test/java$ javac TestGeneric.java
TestGeneric.java:9: error: name clash: mytest(List<String>) and mytest(List<Integer>) have the same erasure
public static String mytest(List<String> s) {
^
1 error

// java 6可以编译通过
xmt@server005:~/test/java$ java -version
java version "1.6.0_31"
Java(TM) SE Runtime Environment (build 1.6.0_31-b04)
Java HotSpot(TM) 64-Bit Server VM (build 20.6-b01, mixed mode)
xmt@server005:~/test/java$ javac TestGeneric.java
xmt@server005:~/test/java$ java TestGeneric

其中 Java 6 能通过编译的原因,是因为 class 文件只需要描述符(返回值不同)不一致就可以共存,并且 Singture 属性保留了泛型信息,所以能正常编译和运行。但是 Java 7 开始,从编译开始就检查泛型的特征签名,所以无法编译通过
参考: Java 泛型重载 jdk 1.7描述符与特征签名

自动装箱和拆箱 (Boxing and Unboxing Conversation)

基本类型自动转换为对应的封装类型(对象)即为自动装箱(Auto Boxing Conversation);反之,封装类型自动转换为基本类型为拆箱(Unboxing Conversation

类型及转换

  • 对应类型
    boolean, byte, short, char, int, long, float, double 对应的封装类型:
    Boolean, Byte, Short, Character, Integer, Long, Float, Double

  • 转换过程
    装箱: *.valueOf(*)
    拆箱:*.intValue()

源码如下,展示了常见的自动装箱和拆箱的用法

1
2
3
4
5
6
7
8
9
10
// 自动装箱
Integer i = 1;
// 自动拆箱
int j = i;

List<Integer> intList = new ArrayList<Integer>();
// 自动装箱
intList.add(1);
// 自动拆箱
int number = intList.get(0);

经过反编译后,可以明确看到装箱和拆箱的动作:

1
2
3
4
5
6
Integer localInteger = Integer.valueOf(1);  // 装箱
int i = localInteger.intValue(); // 拆箱

ArrayList localArrayList = new ArrayList();
localArrayList.add(Integer.valueOf(1)); // 装箱
int j = ((Integer)localArrayList.get(0)).intValue(); // 拆箱

自动装箱后的比较

因为是对象比较,所以建议直接使用 equal 而不是 ==

== 比较的是对象的首地址。但是 Java 做了部分优化,在如下范围内使用的是相同的对象(封装类型的缓存),范围外则在装箱的过程中重新生成一个对象。

  • char
    [\u0000, \u007f]:即 ASCII 码表中的 128 个字符
  • byte/short/int/long
    [-128, 128):在 -128 <= x < 128 范围内,共用相同的对象(缓存),即 byte 能够表达的范围
  • float/double
    没有缓存,直接重新生成一个新对象
  • 示例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // char
    Character ca = '\u007f', cb = '\u007f';
    Character cc = '\u0080', cd = '\u0080';
    System.out.println(ca == cb); //ture
    System.out.println(cc == cd); //false

    // byte, short, int, long: [-128, 128)
    Short sa = -128, sb = -128;
    Short sc = -129, sd = -129;
    Integer ia = 1, ib = 1;
    Integer ic = 128, id = 128;
    Long la = 1L, lb = 1L;
    Long lc = 128L, ld = 128L;
    System.out.println(sa == sb); //true
    System.out.println(sc == sd); //fasle
    System.out.println(ia == ib); //true
    System.out.println(ic == id); //fasle
    System.out.println(la == lb); //true
    System.out.println(lc == ld); //false

String 的自动拆装箱

概念:Java 中的 "abc" 对应的实际是常量,存储在常量池中

1
2
3
4
5
6
7
8
9
10
11
// 对应的都是常量池中 "abc" 的地址
String str1 = "abc";
String str2 = "abc";
System.out.println(str2==str1); //输出为 true
System.out.println(str2.equals(str1)); //输出为 true

// 重新分配了一个对象,所以对象的首地址并不一样
String str3 =new String("abc");
String str4 =new String("abc");
System.out.println(str3 == str4); //输出为 false
System.out.println(str3.equals(str4)); //输出为 true

注意事项

  • 内存空间
    自动装箱涉及到重新生成对象,所以频繁大量的使用会创建很多无用的对象,增加 GC 压力,拉低程序的性能
  • 拆箱空指针异常
    如果封装类型并没有初始化,在拆箱时会报空指针异常,因为编译过程无法检测到,只能在运行时出现,需要特别注意

内存泄露和内存溢出

  • 内存泄露 Memory Leak
    内存泄露是对象在内存中还活着占用内存,但是无法被垃圾回收。指对象到 GC Roots 的引用链
  • 内存溢出 Memory Overflow
    无法申请到足够的内存

垃圾回收

finalize

方法名。Java 技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的。它是在 Object 类中定义的,因此所有的类都继承了它。子类覆盖 finalize() 方法以整理系统资源或者执行其他清理工作。finalize() 方法是在垃圾收集器删除对象之前对这个对象调用的。

Java中所有类都从Object类中继承finalize()方法。

当垃圾回收器(garbage colector)决定回收某对象时,就会运行该对象的finalize()方法。值得C++程序员注意的是,finalize()方法并不能等同与析构函数。Java中是没有析构函数的。C++的析构函数是在对象消亡时运行的。由于C++没有垃圾回收,对象空间手动回收,所以一旦对象用不到时,程序员就应当把它delete()掉。所以析构函数中经常做一些文件保存之类的收尾工作。但是在Java中很不幸,如果内存总是充足的,那么垃圾回收可能永远不会进行,也就是说filalize()可能永远不被执行,显然指望它做收尾工作是靠不住的。

那么finalize()究竟是做什么的呢?它最主要的用途是回收特殊渠道申请的内存。Java程序有垃圾回收器,所以一般情况下内存问题不用程序员操心。但有一种JNI(Java Native Interface)调用non-Java程序(C或C++),finalize()的工作就是回收这部分的内存。

常见问题

多线程并发时,提到 longdouble 类型避免脏数据,需要使用 volatile 修饰?

longdouble 的非原子协定:Java 的内存模型要求,变量的读取操作和写入操作都必须是原子操作的,但是对于非 volatile 类型的 long, double 有些不同,因为这两个变量是 64 位存储,JVM 允许将 64 位的读操作或写操作分解为 2 个 32 位的操作。这样,当在多线程环境中读取一个非 volatilelong, double 变量时,可能会出现读取到这个变量一个值的高 32 位和另一个值的低 32 位,从而导致数据出问题。
但是商用虚拟机基本不会出现这种情况,可以不用过于担心。

多线程并发时需锁住内存模型中的哪一块数据?

并发只影响共享变量:堆内存或方法全局变量。其他线程独享的数据不必关心,比如方法内部变量等等

图书

  1. Java多线程编程核心技术
  2. 深入理解Java虚拟机:JVM高级特性与最佳实践 第2版
  3. Java 规范官方文档
    包含语言规范和 JVM 规范
  4. java并发编程的艺术

参考文档

  1. Java深入学习
  2. Java各种锁
  3. synchronized关键字及实现细节
  4. Java对象锁和类锁全面解析
  5. java线程安全总结
  6. Java中的多线程
  7. 同步和Java内存模型
  8. Java线程安全总结
  9. JVM内存模型
  10. jvm并发算法blog