Java虚拟机总结, 面试前快问快答

文章目录

    • 代码运行原理
    • Java虚拟机运行数据区
    • 对象分配
    • 对象结构和定位对象
    • 判断对象是否存活
    • 收集算法
    • 收集器
    • 字节码文件
    • 类加载机制
      • 类加载器
      • 双亲委派加载
    • Java内存模型
      • 缓存一致性问题
      • Java提供同步的方式
        • 修饰成员方法
        • 修饰代码块
        • 修饰类方法(静态方法)

关于某些内容没有具体的描述, 先占个坑后续补充.

代码运行原理

Java虚拟机总结, 面试前快问快答_第1张图片

Java源代码编译成字节码. 字节码是二进制文件, 用户不能直接查看, 若想查看需要使用javap 命令反编译文件. java命令启动Java虚拟机开始将给定的字节码解释给操作系统. 综上来说, java是编译型+解释型语言.

Java虚拟机运行数据区

Java虚拟机总结, 面试前快问快答_第2张图片
先简单叙述下运行时数据区中每个部分的作用:

  1. 虚拟机栈/本地方法栈: 存储方法的栈帧, 在每个栈帧中保存着方法运行时的局部变量和返回地址等信息. 其中根据调用方法的不同会将栈帧压入不同的栈中, 非本地方法压入虚拟机栈, 而本地方法压入本地方法栈, 本地方法就是被native修饰的方法.
  2. 程序计数器: 存储当前运行指令的位置, 在进行线程切换时, 能够保证恢复的时候读取到线程上次执行的位置. 因此需要每个线程一份.
  3. 堆: 绝大数对象在此区域创建, 该区域按照对象年龄的不同, 还可以细分为新生代老年代.
  4. 方法区: 存储加载的类信息, 以及保存静态变量和常量等信息.

虚拟机栈, 本地方法栈,程序计数器 三者线程私有. 该区域内存的生命周期和线程的生命周期相符, 无需进行额外的释放. 堆和方法区是线程共同使用的, 因此该区域要进行同步和额外的内存管理.

中, 按照对象的年龄可以将划分成不同的区域. 可有细分为年轻代年老代, 默认比例为 1 : 2 1:2 1:2, 而年轻代可以细分为eden(伊甸区)Survivor(存活区), 最后Survivor可以细分为S0S1区. 默认比例 E d e n : S 0 : S 1 = 8 : 1 : 1 Eden:S0:S1=8:1:1 Eden:S0:S1=8:1:1, 新生代只有 90 % 90\% 90% 可以使用.

Java虚拟机总结, 面试前快问快答_第3张图片

对象分配

平时写Java代码时, Object object = new Object() 这行代码会生成一个对象, 运行时引用会保存在虚拟机栈中(new关键字会生成一个对象). 而对象的存储位置在堆中, 具体放置到堆中的位置下面进行讨论.

如果对象较小, 则分配在Eden区, 分配是采用指针碰撞的方式(禁用TLAB, TLAB简单来说就是预先给每个线程分配一块内存) , 在多线程情况下, 同时多个线程分配内存会造成安全问题, 而解决这个问题是通过 CAS的指针碰撞+失败重试 . 在请求分配对象时, JVM会做一些操作保证该类可用, 比如检测运行时常量池中是否有该类的符号引用等(类加载)…

请求分配的对象大小超过一定限制(-XX:PretenureSizeThreshold配置的大小), 则JVM会将对象直接分配在老年代. 大量生命周期短的大对象会造成频繁的服务不可用. 因为要频繁发生FullGC.

对象结构和定位对象

对象结构: 对象头(MarkWord+类型指针) + 实例数据 + 补齐数据

MarkWork是对象运行时的一些信息, 比如HashCode, GC年龄, 锁的状态, 占用线程号等.
类型指针是对象的元数据信息, 表示由那个类所创建.
实例数据是对象自身携带的信息.
补齐数据的存在是因为HotSpot虚拟机要求每个对象的大小为8字节的倍数.

定位对象的方式有直接指针和句柄定位, HotSpot使用直接指针(根据对象头可以看出). 上面提到过新建对象的引用是在虚拟机栈.

直接指针的方式如下图:
Java虚拟机总结, 面试前快问快答_第4张图片
句柄方式如下图:
Java虚拟机总结, 面试前快问快答_第5张图片
采用句柄定位的方式, 想要访问某对象, 需要通过句柄, 而直接指针可以直接定位到对象. 采用句柄的优势是方便GC回收, 虚拟机栈中的对象引用不用修改, 因为回收时句柄的地址不会修改, 只需改动句柄中的数据(对象的地址)即可. 相比句柄, 直接指针可以非常快速的定位到某个对象, 也会提高一部分性能.

定位对象的方式并不影响对象结构

判断对象是否存活

  1. 引用计数法
  2. 可达性算法

引用计数法基本思想是给每个对象一个计数器, 当计数器为0时, 代表没有指向它的引用, 因此计数器为0的对象就应该被回收. 但是这个有一个很严重的问题, 就是互连引用, 假设A和B两个对象互相引用, 则两者的计数器为1, 其他任何对象都没有引用A和B, 那么此时A和B就属于应该被回收的对象, 但是根据计数器的回收规则, 还不能够回收, 计数器不利用其他手段没有办法解决这个问题.

可达性算法中有一个概念叫做GC Root, 以它为出发点, 只要能达到的对象, 都属于存活对象, 而不可达的对象则属于要回收的对象. GC Root并不是只有一个. 能作为GC Root 的角色主要有虚拟机栈栈帧局部变量表的引用. 还有·方法区·中的·静态变量·和·常量·. 最后还有·本地方法栈·中引用的对象, 因为某些方法需要调用本地方法(C/C++实现的方法), 所以需要将对象传输给本地方法栈.

Java虚拟机总结, 面试前快问快答_第6张图片
上图中有2个GC Root, 从两个GC Root为初始点, 开始往下遍历, 最后没有办法到达的点就是要判死的对象.

思考一个问题, 当GC Root非常多的时候, 遍历是非常耗时的, 要遍历现有线程的虚拟机栈和本地方法栈中的栈帧, 还有方法区中的静态变量和常量, 那么怎么能提高效率呢?
利用空间换取时间, 通过准确式内存管理(对象头可知道具体类型, 比如是基本类型,还是引用类型), 预先保存未来要遍历的节点, 发生回收时, 利用保存的节点进行遍历. 保存的数据结构叫做oopMap.

引用的分类:

  1. 强引用
  2. 软引用
  3. 弱引用
  4. 虚引用

收集算法

  1. 标记清除
  2. 复制
  3. 标记整理


Java虚拟机总结, 面试前快问快答_第7张图片
标记清除之后, 产生的白色并非是连续在一起, 在内存中表现为非连续. 而这些非连续的内存称为内存碎片. 当申请的内存超过最大剩余连续内存的容量时, 就不得不发生一次整理.

Java虚拟机总结, 面试前快问快答_第8张图片
关于复制算法的思路是将内存平均划分成两个部分, 任意一个时刻, 只使用一个部分. 另一个部分保留. 这种方法不会产生内存碎片, 因为剩余的内存是一块连续的内存.

Java虚拟机总结, 面试前快问快答_第9张图片
标记整理是在标记清除之后, 对在使用的内存块进行了一次整理, 将他们全部整理到一边, 使剩余的内存表现为一整块连续的内存.

收集器

新生代:

  1. Serial
  2. ParNew
  3. Parallel Scavange

老年代:

  1. Serial Old
  2. Parallel Old
  3. Compare Mark Sweep

新生代 + 老年代

  1. Garbage First (G1)

字节码文件

字节码文件是平台无关性的关键, 因为字节码文件的格式不受具体平台的限制, 只受虚拟机规范影响.
Java虚拟机总结, 面试前快问快答_第10张图片
上图中涉及两个标准, 第一个标准是字节码文件格式标准, 第二个标准是虚拟机规范标准. 只要符合虚拟机规范的字节码文件(Class文件)就可以被虚拟机加载执行. 换句话说, 可以运行在虚拟机上的语言就不只是Java, 其他语言只要能编译成规范的字节码文件, 也可运行在虚拟机上. 关于字节码的具体格式可以看<<深入理解Java虚拟机>>的6.3章节.
Java虚拟机总结, 面试前快问快答_第11张图片
上图来源于<<深入理解Java虚拟机>>, 总的来说只有一个原则: 固定长度字段带长度标识字段组成字节码文件. 固定长度字段很好理解, 例如magic只有固定的4个字节(u4). 而带长度标识字段有两个部分, 首部是长度, 尾部是数据. 例如constant_pool_count, contant_pool, 其中constant_pool_count代表constant_pool的数量. 而constant_pool有自己的结构, 它的结构同样遵循该原则.

第二个标准是虚拟机规范, 各个平台都有虚拟机具体的实现, 他们有一个共同的特点, 遵循着虚拟机的规范. 比如对于不同平台指令集架构的精确语义. 因为在遵循虚拟机的规范情况下, 实现者可以做出各种优化来提高程序的效率.

通过 javap指令阅读字节码, 程序员常常关注的是方法域的Code属性, 在方法域的Code属性中描述了具体执行的指令, 想要读懂该部分的关键是理解虚拟机执行引擎的概念模型和基于栈的指令集架构.

类加载机制

类加载就是将类, 也就是二进制Class文件, 加载进虚拟机中. 关于加载之外的东西, 还需要了解下图的内容.

Java虚拟机总结, 面试前快问快答_第12张图片
类被加载进执行引擎之后, 会经过剩余的一系列操作, 到达最后能使用的地步.

类加载器

Java虚拟机总结, 面试前快问快答_第13张图片
上图是类加载器的层次结构和种类, 关于层次结构在双亲委派中讨论, 先来看个简单的简单示例, 依次打印出创建的系统(应用)加载器, 扩展类加载器启动类加载器.

public static void main(String[] args) {
	// 获取系统(应用)类加载器
    ClassLoader loader = ClassLoader.getSystemClassLoader();
    System.out.println(loader.getClass().getName());
    // 获取系统(应用)加载器的父加载器 ==> 扩展类加载器
    ClassLoader p1 = loader.getParent();
    if (p1 != null) {
        System.out.println(p1.getClass().getName());
        // 获取扩展类加载器的父加载器 ==> 启动类加载器
        ClassLoader p2 = p1.getParent();
        if (p2 != null) {
            System.out.println(p2.getClass().getName());
        }
    }
}

输出结果
上图是它的结果, 发现只有Application ClassLoader系统(应用)加载器和Extension ClassLoader扩展类加载器. 而Bootstrap ClassLoader启动类加载器为null. 那么为什么Bootstrap Loader最后输出为null呢?

在HotSpot虚拟机中, 会用null来表示启动类加载器.
Java虚拟机总结, 面试前快问快答_第14张图片
还有自定义类加载器没有创建, 那么怎么创建一个自定义类加载器呢? 创建自定义类加载器只需要新建自己的类加载器. 如何要按照如下代码写自己的类加载器需要自行保证双亲委派加载方式.

// 创建自定义类加载器, 直接 new ClassLoader() 即可, 但是需要实现自己的方法.
ClassLoader mc = new ClassLoader() {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // doSomeThing, 写出加载类的逻辑.
        // 根据传入的name, 从指定的地方加载, 比如网络, 数据库, 本地磁盘, 但是一般都是从磁盘.
        return null;
    }
};
// 输出其父加载器, 可以看出是 Application ClassLoader.
System.out.println(mc.getParent().getClass().getName());

这么多加载器有什么作用呢? 直接使用一个加载器不行吗? 而该问题的回家就在双亲委派加载, 下面就来谈一下.

双亲委派加载

要说双亲委派加载, 先了解它的三个加载器

  1. 启动类加载器, 负责将 \lib 目录下, 或者被-Xbootclasspath参数指定的路径中, 被虚拟机识别的类库加载进内存中. 在HotSpot它是由C++实现的.
  2. 扩展类加载器, 负责将\lib\ext 目录下, 或者被java.ext.dirs系统变量所指定的路径中的所有类库. 它的实现在是Launcer#ExtClassLoader.
  3. 应用类加载器, 负责加载classpath上指定的类库. 它的实现是Launcher#AppClassLoader

跟踪类加载的详情可以通过, -XX:+TraceClassLoading

而实现双亲委派的代码都在ClassLoader#loadClass()中, 根据下面的代码可以看出, 双亲委派加载某个类的逻辑.

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 没有被虚拟机加载过, 则返回null.
        Class<?> c = findLoadedClass(name);
        if (c == null) {
        	// 没有加载过, 开始加载
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                	// 委托给父加载器加载, 父加载器加载失败, 会抛出ClassNotFoundException
                    c = parent.loadClass(name, false);
                } else {
                	// 委托BootstrapClassLoader加载.
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 没有找到对应的类, 捕获异常, 但是不处理.
            }
            // c == null 意味着父加载器加载失败.
            if (c == null) {
				// 开始尝试自己加载
                long t1 = System.nanoTime();
                // findClass 自己实现, 可以从任何地方读取二进制字节流. JVM没做限制.
                c = findClass(name);
                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
        	// 链接该类.(一个类的周期可以简单描述为 加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 ...)
            resolveClass(c);
        }
        return c;
    }
}

通过上面的了解, 那么应该问自己一个问题? 双亲委派加载有什么优势呢?

Java类跟随Java加载器具有优先级关系. 比如BootstrapClassLoader, 它的优先级最高. 只要使用双亲委派加载方式, 加载的所有类都需要先被Bootstrap ClassLoader先尝试加载, Bootstrap ClassLoader只会加载认为是安全的, 有效的类, 用户自定义的二进制Class文件放到Bootstrap ClassLoader的加载路径也不会被加载, 因为它会认为不应该是被自己加载, 并且抛出ClassNotFoundException, 让下一个加载器尝试去加载. 保持这个加载顺序, 能够保证Java核心API类库不会在运行时被修改.

怎么破坏双亲委派加载呢?
通过前面代码了解到, 保证双亲委派加载的机制在ClassLoader#loadClass中, 如果实现者自己实现, 而没有调用父加载器, 那么加载的时候就会出错, 因为加载某个类, 要保证父类先加载, 在Java中, 所有的类都是java.lang.Object的子类, 因此也会加载java.lang.Object, 若自己写的没有使用双亲委派加载则会出错. 那么如何保证自己写的类加载器保证双亲委派加载机制呢?

在jdk1.2以后, 提供了ClassLoader#findClass方法, 用户只需要覆写该方法, 最后返回加载到的Class对象. 在前面的ClassLoader#loadClass方法中可以看出, 任何期望加载的类, 都在先调用父加载器, 因此只有父加载器加载不成功之后, 才会调用实现者的逻辑ClassLoader#findClass.

// findClass的默认实现.
protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

什么情况下需要破坏双亲委派加载呢?
在一般情况下, 破坏双亲委派加载是为了代码热替换(HotSwap), 模块热部署(Hot Deployment), 能够做到程序不停止, 就做到程序的升级替换.

关于OSGI 先留坑, 若有用到再了解.

Java内存模型

Java内存模型的出现是为了屏蔽各种硬件和操作系统访问内存的差异, 以便能让Java程序在各种平台下访问内存达到一致的效果.Java虚拟机总结, 面试前快问快答_第15张图片

在计算机硬件体系中, CPU, 缓存和内存的关系和图中大体一致. 从这方面可以看出计算机的很多思想都是互通的.

缓存一致性问题

在硬件层面, 内存之上设有缓存(cache), CPU只能从缓存中取数据, 缓存与内存相比: 优势是存取速度快, 劣势是容量小, 假设CPU访问缓存的时间是2ms, 则直接访问内存的时间20ms. 在Java内存模型中, 本地内存可以认为是缓存, 而主存对应为内存. 在访问数据的时候, 会将数据从主存拷贝到本地内存, 如果同一份数据拷贝到不同的本地内存, 同时有多个Java线程操作, 则就会引发缓存一致性问题.

在Java中怎么解决缓存一致性问题呢?

在Java中想要解决缓存一致性问题最轻量级的方法就是给变量用volatile修饰. 被volatile修饰的变量,只要发生了修改, 其他的线程就可以立即观察到. 或者使用final修饰, 一旦初始化完成, 所有线程看到的都是一个值, 因为其不支持修改.

但是解决了缓存一致性之后, 是否并发操作就是安全的呢? 答案是不是的, 想要并发安全, 必须满足可见性,原子性,有序性. 而从上面了解到volatile可以支持可见性, 但是它其实也同时支持了有序性, 因为它禁止了该变量的指令重排序, 在修改被volatile修饰的变量之后插入内存屏障, 表示指令重排序不能将指令排到内存屏障之前.

可见性就是因为缓存原因导致, 多个CPU使用不同缓存导致.
原子性是CPU执行一个操作中不能被打断, 不会被其他线程干扰.
有序性是指令执行的顺序不要使用优化. 硬件的指令重排序就是在结果不变的情况下, 调整执行顺序来提高效率.

想要保住并发安全, 需要同时满足三个特性, 在Java中可以用Synchronized修饰, Synchronized能修饰成员方法, 类方法, 代码块.

Java提供同步的方式

修饰成员方法

修饰成员方法.然后该成员方法就具有同步功能,无论多少个线程调用同一个实例的同步(synchronized)方法,都会获得该实例的锁.一个实例只有一把锁,因此一旦有线程获得了锁,则其他线程调用该实例的同步方法时就会发生阻塞.

public class SynchronizedDemo implements Runnable {
	static int i = 0;
	public static void increment() { i++; }

	@Override
	public synchronized void run() {
		for (int i = 0; i < 100000; i++) {
			increment();
		}
	}

	public static void main(String[] args) throws InterruptedException {
		final SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
		final Thread thread1 = new Thread(synchronizedDemo);
		final Thread thread2 = new Thread(synchronizedDemo);
		thread1.start();
		thread2.start();
		thread1.join();
		thread2.join();
		System.out.println(i);
	}
}
该实例最后得出的结果为20000,说明同步成功.

修饰代码块

修饰代码块需要指定一个标志,这个标志可以是类类,实例(要求必须是Object)

public class SynchronizedDemo implements Runnable {
	static int i = 0;
	public static void increment() { i++; }

	@Override
	public void run() {
		for (int i = 0; i < 100000; i++) {
			// 使用类类作为标志,也可以使用实例,比如this作为标志
			// 如果使用this,效果就相等于范围缩小版的同步成员方法
			// 也可以是其他实例,其他实例,同样的道理.
			synchronized (SynchronizedDemo.class) {
				increment();
			}
		}
	}

	public static void main(String[] args) throws InterruptedException {
		final SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
		// 两个不同的线程使用同一个实例,得出的结果也是同步之后的结果
		// 这里的synchronized是以类类为标志,这个的所有实例公用一个
		final Thread thread1 = new Thread(synchronizedDemo);
		final Thread thread2 = new Thread(synchronizedDemo);
		thread1.start();
		thread2.start();
		thread1.join();
		thread2.join();
		System.out.println(i);
	}
}

修饰类方法(静态方法)

修饰在静态方法(类方法)上,会将本类的类类作为标志(锁),和方法二中使用类类能达到一样的效果.但是这样的范围比较大.不够灵活.

public class SynchronizedDemo implements Runnable {
	static int i = 0;
	// synchronized 修饰在静态方法,以类类作为标志,不同实例也会阻塞.
	public synchronized static void increment() { i++; }

	@Override
	public void run() {
		for (int i = 0; i < 100000; i++) {
			increment();
		}
	}

	public static void main(String[] args) throws InterruptedException {
		// 这里使用两个实例来运行
		final Thread thread1 = new Thread(new SynchronizedDemo());
		final Thread thread2 = new Thread(new SynchronizedDemo());
		thread1.start();
		thread2.start();
		thread1.join();
		thread2.join();
		System.out.println(i);
	}
}

你可能感兴趣的:(Java)