《深入理解Java虚拟机》读书笔记


世间万物皆系于四箭之上

尽管这本书是一本讲述Java的书籍,但是这本书的内容却并不只是针对Java而言。而是针对计算机整个底层的规划,如何通过底层的设计来创造出合理便捷的语言。底层开发人员需要了解上层的应用而设计合理的底层结构,上层开发人员需要连接底层的结构来更好的理解程序的内部逻辑。

程序的运行流程:
编写好的Java文件,首先通过编译器编译为class字节码文件,在这个过程中,虚拟机会对类信息、变量、方法等等信息进行一个排序。之后,运行时,虚拟机会对通过字节码文件的描述,对内存进行划分,安排堆内存、占内存等等,同时通过栈对方法进行一个顺序操作执行。即编译期:先把文件转为二进制字节码,方便运行时解析。运行期:根据字节码文件分配内存,完成执行操作


第二部分:自动内存管理机制

第2章:Java内存区域与内存溢出的异常
相对于C++而言,Java的好处是开发者并不需要过多的在意内存的回收,这些都有jvm进行处理。但即便这样,也会有一个问题,过度的依赖机器,会出现未知的问题。因而,也需要开发人员做到心中有数。

2.2 运行时数据区域
运行数据区

  • 线程私有区
    每个线程都会独有一个

    • 程序计数器:程序计数器用于指示当前线程的执行位置,字节码行号指示器
    • 虚拟机栈:可以看作是一个栈结构,入栈出栈的过程就相当于方法的调用和完成过程。调用单位是栈帧。即每个栈帧都是一个单独的方法,其中包含方法内部的所有数据信息,包括:方法的局部变量表、操作数栈、动态链接、方法出口等
      • 局部变量表:局部变量表保存了方法的局部基础变量和引用对象的指针,一般以4个字节为一个slot进行存储,其中long和double占用2个slot。局部变量的大小设定,在编译器就已经完成,只需要在运行期进行分配就行。这样便于程序的快速运行
    • 本地方法栈:即居民内部的native方法
  • 线程共享区

    • Java堆:用于存放实例对象和数组,这个世内存管理的重点,后续再说
    • 方法区:用于存储类信息、常量池、静态变量等数据。相当于保存了Java程序的结构信息,而不是具体数据
      • 运行时常量池:字面量和符号引用。主要保存一些基础的常量,包括String字符串、final修饰的常量。这些变量运行时基本不会变化,直接保存,运行时直接赋值,减少分配内存的开销。
  • 直接内存:共享的对外内存。对于数据传输而言,由于数据需要经常进行搬运,如果直接使用堆内存,会进行频繁的调用,因而,jdk1.4中,对于NIO的使用,指定可以直接对外部存储进行调用,大大提高了效率。但是这也会带来OOM异常,但是很多时候不容易发现,因而在使用NIO时,需要重点注意这个

2.3 虚拟机中对象的流程

  • 对象的创建:
    一下只包括New 对象,不包括数组和class对象

    • 遇到一个new指令后,会在常量池中查找对应的符号引用,这个符号指引的类,在加载后就有了相关的信息。
    • 为对象分配所需的内,一下为分配策略
      1.指针碰撞:按顺序实用内存,使用一个指针标记最终的使用位置(一般用于内存规整的情况)
      2.空闲列表:随意存放,但是通过一个列表,记录内存的空闲区域。用于后续分配的存放
    • 多线程访问的解决:1.CAS操作-2.单独为每个线程分配空间,再整合
    • 将内存空间初始化为0值
    • 设置对象的一些信息,包括hash码、gc年龄等
    • init方法初始化
  • 对象的内存布局
    对象的内存分3个区域:

    • 对象头:包括对象的所有信息和对象所属类的类型指针
    • 对象数据
    • jvm的起始地址必须是8的整数,所以不够的需要填充
  • 对象的访问定位
    程序运行时通过栈上的引用来对堆中的对象进行操作,所以需要定义指针如何找到对象,一般一下两种

    • 使用指针:即直接指向对象地址
    • 使用句柄:通过一个句柄池,间接指向对象
      句柄的优势在于,如果对象地址改变,只需要修改句柄,而不需要修改引用。但是指针引用效率更高(hotspot使用指针)
      句柄和指针区别

常见错误的归纳:

// 1.堆溢出:不断地创建对象
// java.lang.OutOfMemoryError: Java heap space
 List<OOMObject> list = new ArrayList<OOMObject>();
  while (true) {
		 list.add(new OOMObject());
  }
  
// 2 虚拟机栈和本地方法栈:
// 2.1 方法的递归调用
// java.lang.StackOverflowError-JavaVMStackSOF.java:20
public void stackLeak() {
	  stackLength++;
	  stackLeak();
}

// 2.2 创建太多的线程,每个线程都要单独分配
// java.lang.OutOfMemoryError: unable to create new native thread
while (true) {
		 Thread thread = new Thread(new Runnable() {
				@Override
				public void run() {
					   dontStop();
				}
		 });
		 thread.start();
}

// 3 运行时常量池-不断地创建字符串常量
// OutOfMemoryError: PermGen space - RuntimeConstantPoolOOM.java:18
public static void main(String[] args) {
	  // 使用List保持着常量池引用,压制Full GC回收常量池行为
	  List<String> list = new ArrayList<String>();
	  // 10M的PermSize在integer范围内足够产生OOM了
	  int i = 0;
	  while (true) {
			 list.add(String.valueOf(i++).intern());
	  }
}

// 4 方法区-需要加载的类信息过多
// Spring、Hibernate对类进行增强时,需要加载大量数类信息,会存在这种情况
// OutOfMemoryError: PermGen space-ClassLoader.java:632
while (true) {
	 Enhancer enhancer = new Enhancer();
	 enhancer.setSuperclass(OOMObject.class);
	 enhancer.setUseCache(false);
	 enhancer.setCallback(new MethodInterceptor() {
			public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
				   return proxy.invokeSuper(obj, args);
			}
	 });
	 enhancer.create();
}

// 5 本机直接内存
// OutOfMemoryError-DirectMemoryOOM
/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 * @author zzm
 */
public class DirectMemoryOOM {
       private static final int _1MB = 1024 * 1024;
       public static void main(String[] args) throws Exception {
              Field unsafeField = Unsafe.class.getDeclaredFields()[0];
              unsafeField.setAccessible(true);
              Unsafe unsafe = (Unsafe) unsafeField.get(null);
              while (true) {
                     unsafe.allocateMemory(_1MB);
              }
       }
}

第3章:垃圾收集器与内存分配策略

3.2 对象死亡的判断
一个对象是否可以被回收,可以通过一下方法:

  • 引用计数:每个对象都有一个自身的引用计数器。当计数为0时,就判断死亡。无法解决互相引用
  • 可达性分析:记录一个根节点到每个对象的引用,相当于树

java中的四种引用:

  • 强引用:即一般的引用
  • 软引用:内存发生溢出前,就回收
  • 弱引用:gc时,就被回收
  • 虚引用:外界不知道它的存在,只是在回收时,关联着会收到系统通知

对象的两次判断:
无论是可达性还是计数器,对象在回收前,会调用一次finalize方法,让然后,等待回收,如果期间再次被引用,就可以被救起。finalize方法只会调用一次。

3.3 垃圾收集算法

  • 标记-清除:对每个对象进行遍历标记,再次遍历时,清除没有引用的对象(存在碎片)
  • 复制算法:将内存分为两块大小相对的区域,一部分用于存储,另一部分用于复制(但是内存只能用一半)一般用于新生代(eden-survivor-survivor)
  • 标记-整理:第一次遍历对对象进行标记,存活的对象,移动到一端;然后直接将外界的内存全部清理。这种方式一般用于老年代。即高效,又节省资源
  • 分代收集:这种方式是综合前面的算法:新生代使用复制,老年代使用标记-整理;

3.5 垃圾收集器
垃圾收集器是垃圾回收的具体实现

  • Serial:单线程,独占(需要用户等待)。一般用于客户端模式:客户端垃圾不多,单个线程完全够用,停顿少
  • ParNew:Serial的多线程并行版本,独占。用于服务器端新生代收集器:依赖多线程。配合老年代使用CMS
  • Parallel Scavenge:新生代复制算法收集器,并发。与ParNew不同的是,他通过一个吞吐量来控制并行。适应实际应用-即自适应调节策略。
  • CMS-并发标记清除:目的是最短停顿时间:主要是四个步骤:
    • 初始标记
    • 并发标记
    • 重新标记
    • 并发清除
  • G1收集器:当前主流
    • 初始标记
    • 并发标记
    • 最终标记
    • 筛选标记

enter description here
enter description here

并行和并发的区别:
enter description here
文中的上面的并行指的是所有的垃圾收集器是并行的,用户等待
并发时用户和垃圾线程并发执行,可能互相交叉

GC日志:
enter description here

3.6 垃圾回收策略

  • 对象优先分配在新生代Eden+一个Survivor区。
    当新生代不足以分配内存时,就会发生一次Minor DC,GC后,存下来的对象放入另一个Survivor,如果内存不够,直接进入老年代。

  • 大对象直接进入老年代。
    当一个大对象放入新生代的时候,由于大的占用,会引发不断地GC,因此,为了防止这种情况,大对象直接放入老年代。但是这样也会出现另一个问题,即大对象一般存活时间短,直接放到老年代,存活时间长,对老年代也是压力。所以需要程序员合理设置最大值

  • 长期存活的对象进入老年代
    对于存活下来的对象,进行年龄统计,到达一定的年龄,也会进入老年代。如果某个年龄的总数大于对象数量的一般,也会将当前年龄的对象全部放入老年代。一般15岁。

  • 老年代的fullGC
    在发生Minor GC前,老年代会检查,可用空间是否大于新生代所有空间,如果大于,表明没有风险,可以全部容纳,就Minor GC。
    再检查老年代可用空间是否大于历次晋升对象的平均大小,如果大于,就MinorGC,否则进行一次Full GC。(仍然有风险,只是概率问题)


第4章 虚拟机监控工具

4.2 命令行工具-bin目录下

// java process status 查看所有的java线程
jsp - l
// 虚拟机持续监控-每250秒对2726线程监控垃圾收集情况,总共20次
jstat -gc 2726 250 20
// java配置信息
jinfo
// java对堆栈跟踪工具-输出线程快照
jstack -l 3500

可视化工具:

  • jconsole
  • VisulaVm插件

第5章: 实战优化案例


第三部分:虚拟机执行子系统

第6章 类文件结构

6.3 class文件结构

class文件结构以一个字节作为单位,使用16进制表示,即两个16进制位。

  • 魔数和class版本:都为4个字节,代表是否能被虚拟机加载;版本便是当前文件版本号
  • 常量池:class文件最大的数据区,前2个字节代表常量数量;之后为常量数:包括常量和引用
  • 访问标志:2个字节。表示类的访问信息:类还是接口,public还是abstract,final等
  • 类索引、父类索引、接口集合:用于确定类的全限定名。单继承,所以,类索引和父类索引都是2个字节,接口是一组u2的组合,第一个表示数量。
  • 字段表集合:用于描述接口和类中声明的变量。不包括方法局部变量。第一个代表数量
  • 方法表集合:同字段表一样
  • 属性表集合:支持前面的信息

6.4 字节码指令
类似于汇编的寄存器操作

  • 加载存储指令:load是将局部变量加载到操作栈,store是将从操作栈存储到局部变量表,push是将常量加载到操作栈
  • 运算指令:add、sub等
  • 类型转换:支持向上转换,向下转换会丢失精度。补充:long可以上升到float,float是存储10的指数次方+有效位数,所以范围很大。
  • 对象创建与访问指令:new 、 newarray、getfield、aload、instanceof
  • 操作数栈管理指令:pop、pop;dup、dup2;swap
  • 控制转移:ifeq、ifle;相当于条件判断
  • 方法调用和返回:invoke、return
  • 异常处理:athrow直接抛出;catch使用的是异常表而不是指令
  • 同步指令:获取对象的监视器Monitor,通过acc_syn访问表示,识别是否进入监视器

第7章 类加载机制

java中,类的加载、连接及初始化都是在运行期间完成的。
周期:加载-验证-准备-初始化-卸载

初始化规定:

  1. 遇到new、静态字段、静态方法
  2. 使用reflect对类进行反射调用时
  3. 初始化类,先初始化父类(如果父类没初始化)
  4. 虚拟机启动需要一个主类,主类需要先初始化
  5. 动态语言情况

注意以上的一些案例

7.3 类加载过程

  • 1加载
    3件事情

    1. jvm通过类的全限定名获取类的二进制流
    2. 将表示的静态数据结构转为方法区运行时的数据结构
    3. 在内存生成类的对象,用于方法调用
  • 2验证
    验证是否符合jvm要求

  • 3准备
    为类变量分配内存,并初始化。

  • 4解析
    将常量池的符号引用转为直接引用

  • 5初始化-开始执行类中的代码

7.4 类加载器:
类的加载需要合理的管理,否则会存在混乱,同样的名字,代表的内存却不是一样的。所以,这里使用双亲委派模型,即每个类的加载都向上传递,交由父类进行加载,如果父类加载失败,才由自身加载,防止父类和自身加载冲突。即保证只有一个被加载


第8章 虚拟机字节码执行引擎

执行引擎就是通过给定的字节码指令,执行对应的调用过程

8.2 运行时栈帧结构
一个线程会独占一个栈,栈是由多个栈帧组成,每一个栈帧相当于一个线程方法。包含了操作所需的局部变量、操作数栈、动态链接等。线程的执行过程就是方法入栈出栈的过程。栈顶栈帧叫做当前帧,即运行时帧。这些变量的内存占用在编译时就已经确定
enter description here

  • 局部变量表:
    即方法内部变量,包括方法的参数和方法的内部定义变量。
    局部变量表组成:局部变量表有10种变量:8种基础变量,1个对象引用变量(包括字符串的引用),1个字节码地址(现已不用)。参数的占用以4个字节为一个slot单位,不足补齐。所以long、double占用两个slot。
    局部变量表的排序:对于一个栈帧中单独一个方法而言,主要由三部分组成:如果方法是实例方法,那么第一部分参数就是对象的引用,默认隐藏,即this;第二个为方法参数;第三部分为方法内部变量;

slot复用:为了节省栈帧内存,Java局部变量表slot可以复用。即在一个方法内部,代码块中的变量生存周期只在代码块内部,如果出了代码块,即便有引用,也会被回收。但是,这种回收也是不确定的,所以,一个编码规则是:程序员需要主动对不用的对象,赋值为null

public static void main(String[] args) {
        {
            int[] temp = new int[2014];
        }
        int t = 0; // 加上这句,主动去复用,才会回收temp,否则,不回收,尽量直接使用temp=null
        System.gc();
    }
  • 操作数栈:
    虚拟机栈是方法调用的栈结构,操作数栈是方法内部计算的栈结构。即常见的表达式计算。操作数栈主要处理两类数据:一类是已知的基本数据的加减乘除,另一类是方法传递的加减乘除(即方法返回的结果等)

  • 动态链接
    虚拟机线程共享区有一个方法区,方法区中有一个常量池,用于存储固定的常量和方法名的引用(便于直接查找方法)。动态链接保存的就是当前栈帧需要用到的方法引用,便于运行时直接调用。

  • 方法返回地址:
    即方法调用完成后,需要恢复上层局部变量表和操作数栈,并将返回值压入操作数栈,并调用PC计数器指向下一个指令。如果运行期出现未处理异常,就会直接导致方法退出

8.3 方法调用
方法调用不同于方法执行,调用只是设定方法调用的引用,相当于把所有的方法通过链表连接起来,作为一个调用顺序。
以下为几种调用方式:

  • 解析调用:在class文件时,方法的调用存储的只是方法的符号引用。直到类加载的解析阶段,才会将一部分方法的符号引用转化为直接引用,即最终的引用。这包括invokestatic和invokespecial,即静态方法和私有方法、实例构造器、父类方法4类;另外还有final方法。可以发现这五个类型是一种不变的类型,即方法的指引对象在运行期不会变化(这个需要注意Java多态)
    解析调用是一个静态的过程,即在编译期就完全确定,不用等到运行期。

  • 分派调用
    分派调用时Java多态的一种实现,即重载和重写。分为静态分派和动态分派。
    静态分派即在编译器完成分派,即直接能确定的;动态分派是指在运行期完成分派,需要运行时才知道确定类型的。
    比如Human man = new Man();man变量有两个类型,分别是编译期和运行期,编译期是Human,即静态类型;运行期是Man,即实际类型。

重载:重载的参数是在编译器确定的
重写:重写的参数是在运行期确定的

比较:

// 1-静态分派-重载
public class StaticDispatch {
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }

    static abstract class Human { }

    static class Man extends Human { }

    static class Woman extends Human { }

    public void sayHello(Human guy) {
        System.out.println("hello, guy");
    }

    public void sayHello(Man guy) {
        System.out.println("hello, man");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello, woman");
    }
}

// output:
hello, guy
hello, guy


// 2-动态分派-重写
public class DynamicDispatch {
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }

    static abstract class Human { 
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        } 
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        } 
    }
}
// outptu:
man say hello
woman say hello
woman say hello

重载的调用顺序:
当前指定参数-向上类型转换-自动装箱-接口向上-父类向上-可变长参数
类型转换:char-int-long-float-double

  • 单分派和多分派
    java中的分派,根据两个参数确定。一个是调用的对象,一个是方法的参数对象。
    如果只使用一个就是单分派,如果两个都是用就是多分派。
    Java中的分派是:静态多分派,动态单分派。
    比如A.invoke(B):静态分派既要知道A的类型,也要知道B的类型;而动态分派只需要知道A的类型,就可以确定调用哪个方法

动态分派太过繁杂,为了便于查找,java在方法区中使用了一个虚拟机表,用于保存方法的索引,便于查找

java基于栈的指令集和汇编基于寄存器的指令集
对于1+1操作:

// 基于栈:把两个元素取出来操作,再放回
iconst_1  
iconst_2  
iadd  
istore_0  

// 基于寄存器:直接在单个元素上操作,第二个参数是确定的数
mov  eax, 1  
add  eax, 2  
展开阅读全文

没有更多推荐了,返回首页