面试题java核心知识web学习

Java 底层机制

2016-05-04  本文已影响4492人  zhazhaxin

JVM体系结构

JVM是一种解释执行class文件的规范技术。

JVM体系结构

我翻译的中文图:

中文图

类装载器子系统

在JVM中负责装载.class文件(一种8位二进制流文件,各个数据项按顺序紧密的从前向后排列, 相邻的项之间没有间隙,经编译器编译.java源文件后生成,每个类(或者接口)都单独占有一个class文件)。

运行时数据区

方法区

JVM使用类装载器定位class文件,并将其输入到内存中时。会提取class文件的类型信息,并将这些信息存储到方法区中。同时放入方法区中的还有该类型中的类静态变量

常量池

常量池指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。除了包含代码中所定义的各种基本数据类型和对象型(String及数组)的常量值(final,在编译时确定,并且编译器会优化)还包含一些以文本形式出现的符号引用(类信息),比如:

虚拟机必须给每个被装载的类型维护一个常量池。常量池就是该类型所用到常量的一个有序集合,包括直接常量(string、integer等)和其他类型,字段和方法的符号引用

方法区是多线程共享的。也就是当虚拟机实例开始运行程序时,边运行边加载进class文件。不同的Class文件都会提取出不同类型信息存放在方法区中。同样,方法区中不再需要运行的类型信息会被垃圾回收线程丢弃掉

堆内存

Java 程序在运行时创建的所有类型对象和数组都存储在堆中JVM会根据new指令在堆中开辟一个确定类型的对象内存空间。但是堆中开辟对象的空间并没有任何人工指令可以回收,而是通过JVM的垃圾回收器负责回收

程序计数器

对于一个运行的Java而言,每一个线程都有一个PC寄存器。当线程执行Java程序时,PC寄存器的内容总是下一条将被执行的指令地址

Java栈

每启动一个线程JVM都会为它分配一个Java栈,用于存放方法中的局部变量,操作数以及异常数据等。当线程调用某个方法时,JVM会根据方法区中该方法的字节码组建一个栈帧。并将该栈帧压入Java栈中,方法执行完毕时,JVM会弹出该栈帧并释放掉。

注意Java栈中的数据是线程私有的,一个线程是无法访问另一个线程的Java栈的数据。这也就是为什么多线程编程时,两个相同线程执行同一方法时,对方法内的局部变量是不需要数据同步的原因

java栈和局部变量详解

成员变量有默认值(被final修饰且没有static的必须显式赋值),局部变量不会自动赋值

执行引擎

运行Java的每一个线程都是一个独立的虚拟机执行引擎的实例。从线程生命周期的开始到结束,他要么在执行字节码,要么在执行本地方法。一个线程可能通过解释或者使用芯片级指令直接执行字节码,或者间接通过JIT(即时编译器)执行编译过的本地代码。

注意JVM是进程级别,执行引擎是线程级别

指令集

实际上,class文件中方法的字节码流就是有JVM的指令序列构成的。每一条指令包含一个单字节的操作码,后面跟随0个或多个操作数

指令由一个操作码和零个或多个操作数组成。

iload_0    // 把存储在局部变量区中索引为0的整数压入操作数栈。
iload_1    // 把存储在局部变量区中索引为1的整数压入操作数栈。
iadd         // 从操作数栈中弹出两个整数相加,在将结果压入操作数栈。  
istore_2   // 从操作数栈中弹出结果  

很显然,上面的指令反复用到了Java栈中的某一个方法栈帧。实际上执行引擎运行Java字节码指令很多时候都是在不停的操作Java栈,也有的时候需要在堆中开辟对象以及运行系统的本地指令等。但是Java栈的操作要比堆中的操作要快的多,因此反复开辟对象是非常耗时的。这也是为什么Java程序优化的时候,尽量减少new对象。

示例分析

//源代码 Test.java  
package edu.hr.jvm;  
  
import edu.hr.jvm.bean;  
public class Test{  
       public static void main(String[] args){  
               Act act=new Act();  
               act.doMathForever();  
       }  
}  
  
//源代码 Act.java  
package edu.hr.jvm.bean;  
  
public class Act{  
       public void doMathForever(){  
              int i=0;  
              for(;;){  
                     i+=1;  
                     i*=2;   
              }  
       }  
}  
示例

编译和运行过程

编译:源码要运行,必须先转成二进制的机器码。这是编译器的任务。

运行java类运行的过程大概可分为两个过程:类的加载,类的执行。需要说明的是:JVM主要在程序第一次主动使用类的时候,才会去加载该类。也就是说,JVM并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次

下面是程序运行的详细步骤

//MainApp.java  
public class MainApp {  
    public static void main(String[] args) {  
        Animal animal = new Animal("Puppy");  
        animal.printName();  
    }  
}  
//Animal.java  
public class Animal {  
    public String name;  
    public Animal(String name) {  
        this.name = name;  
    }  
    public void printName() {  
        System.out.println("Animal ["+name+"]");  
    }  
}  

特别说明:java类中所有public和protected的实例方法都采用动态绑定机制,所有私有方法静态方法构造器初始化方法<clinit>都是采用静态绑定机制。而使用动态绑定机制的时候会用到方法表,静态绑定时并不会用到。

通过前面的两个例子的分析,应该理解了不少了吧。

类加载机制

JVM主要包含三大核心部分:类加载器,运行时数据区和执行引擎

虚拟机将描述类的数据从class文件加载到内存,并对数据进行校验,准备,解析和初始化,最终就会形成可以被虚拟机使用的java类型,这就是一个虚拟机的类加载机制。java在类中的类是动态加载的,只有在运行期间使用到该类的时候,才会将该类加载到内存中,java依赖于运行期动态加载和动态链接来实现类的动态使用。

一个类的生命周期:

Paste_Image.png

加载,验证,准备,初始化和卸载在开始的顺序上是固定的,但是可以交叉进行。
在Java中,对于类有且仅有四种情况会对类进行“初始化”。

注意

类加载过程

加载

加载阶段主要完成三件事,即通过一个类的全限定名来获取定义此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在Java堆中生成一个代表此类的Class对象,作为访问方法区这些数据的入口。这个加载过程主要就是靠类加载器实现的,这个过程可以由用户自定义类的加载过程。

验证
这个阶段目的在于确保才class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。
主要包括四种验证:

准备
仅仅为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即零值,这里不包含用final修饰的static,因为final在编译的时候就会分配了(编译器的优化),同时这里也不会为实例变量分配初始化。类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

解析
解析主要就是将常量池中的符号引用替换为直接引用的过程。符号引用就是一组符号来描述目标,可以是任何字面量,而直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。有类或接口的解析,字段解析,类方法解析,接口方法解析。

初始化
初始化阶段依旧是初始化类变量和其他资源,这里将执行用户的static字段和静态语句块的赋值操作。这个过程就是执行类构造器< clinit >方法的过程。
< clinit >方法是由编译器收集类中所有类变量的赋值动作和静态语句块的语句生成的,类构造器< clinit >方法与实例构造器< init >方法不同,这里面不用显示的调用父类的< clinit >方法,父类的< clinit >方法会自动先执行于子类的< clinit >方法。即父类定义的静态语句块和静态字段都要优先子类的变量赋值操作。

类加载器

类加载器的分类

类加载器的特点

类加载器的双亲委派模型

类加载器双亲委派模型的工作过程是:如果一个类加载器收到一个类加载的请求,它首先将这个请求委派给父类加载器去完成,每一个层次类加载器都是如此,则所有的类加载请求都会传送到顶层的启动类加载器,只有父加载器无法完成这个加载请求(即它的搜索范围中没有找到所要的类),子类才尝试加载。

使用双亲委派模型主要是两个原因:

下面是一个类加载器双亲委派模型,这里各个类加载器并不是继承关系,它们利用组合实现的父类与子类关系。


双亲委托模型

类加载的几种方式

类加载实例

当在命令行下执行:java HelloWorld(HelloWorld是含有main方法的类的Class文件)JVM会将HelloWorld.class加载到内存中,并在堆中形成一个Class的对象HelloWorld.class

基本的加载流程如下:

创建自己的类加载器

Java应用开发过程中,可能会需要创建应用自己的类加载器。典型的场景包括实现特定的Java字节代码查找方式、对字节代码进行加密/解密以及实现同名Java类的隔离等。创建自己的类加载器并不是一件复杂的事情,只需要继承自java.lang.ClassLoader类并覆写对应的方法即可。 java.lang.ClassLoader中提供的方法有不少,下面介绍几个创建类加载器时需要考虑的:

这里比较 容易混淆的是findClass()方法和loadClass()方法的作用。前面提到过,在Java类的链接过程中,会需要对Java类进行解析,而解析可能会导致当前Java类所引用的其它Java类被加载。在这个时候,JVM就是通过调用当前类的定义类加载器的loadClass()方法来加载其它类的。findClass()方法则是应用创建的类加载器的扩展点。应用自己的类加载器应该覆写findClass()方法来添加自定义的类加载逻辑。 loadClass()方法的默认实现会负责调用findClass()方法
前面提到,类加载器的代理模式默认使用的是父类优先的策略。这个策略的实现是封装在loadClass()方法中的。如果希望修改此策略,就需要覆写loadClass()方法

下面的代码给出了自定义的类加载的常见实现模式:

public class MyClassLoader extends ClassLoader {   
   protected Class<?> findClass(String name) throws ClassNotFoundException {       
      byte[] b = null; //查找或生成Java类的字节代码       
      return defineClass(name, b, 0, b.length);   
   }
}

Java垃圾回收机制

Java堆内存

分代收集

新生代(Young Generation)

老年代(Old Generation)实例将从S1提升到Tenured(终身代)
永久代(Permanent Generation)包含类、方法等细节的元信息

enter image description here

永久代空间在Java SE8特性中已经被移除。

垃圾回收过程

enter image description here

年轻代:使用标记复制清理算法,解决内存碎片问题。因为在年轻代会有大量的内存需要回收,GC比较频繁。通过这种方式来处理内存碎片化,然后在老年代中通过标记清理算法来回收内存,因为在老年代需要被回收的内存比较少,提高效率。
Eden 区:当一个实例被创建了,首先会被存储在堆内存年轻代的 Eden 区中。

Survivor 区(S0 和 S1):作为年轻代 GC(Minor GC)周期的一部分,存活的对象(仍然被引用的)从 Eden区被移动到 Survivor 区的 S0 中。类似的,垃圾回收器会扫描 S0 然后将存活的实例移动到 S1 中。总会有一个空的survivor区

老年代: 老年代(Old or tenured generation)是堆内存中的第二块逻辑区。当垃圾回收器执行 Minor GC 周期时(对象年龄计数器),在 S1 Survivor 区中的存活实例将会被晋升到老年代,而未被引用的对象被标记为回收。老年代是实例生命周期的最后阶段。Major GC 扫描老年代的垃圾回收过程。如果实例不再被引用,那么它们会被标记为回收,否则它们会继续留在老年代中。

内存碎片:一旦实例从堆内存中被删除,其位置就会变空并且可用于未来实例的分配。这些空出的空间将会使整个内存区域碎片化。为了实例的快速分配,需要进行碎片整理。基于垃圾回收器的不同选择,回收的内存区域要么被不停地被整理,要么在一个单独的GC进程中完成。

根可达性算法

Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾收集算法一般要做2件基本的事情:

  • 发现无用信息对象
  • 回收被无用对象占用的内存空间,使该空间可被程序再次使用。

GC Roots
根集就是正在执行的Java程序可以访问的引用变量的集合(包括局部变量、参数、类变量)

GC Roots的对象包括

**可达性算法分析 **

通过一系列称为”GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所有走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(从GC Roots到此对象不可达),则证明此对象是不可用的,应该被回收。

根搜索算法:计算可达性,如图:

根搜索算法

垃圾回收算法

引用计数法

引用计数法是唯一没有使用根集(GC Roots)的垃圾回收的法,该算法使用引用计数器来区分存活对象和不再使用的对象。堆中的每个对象对应一个引用计数器。当每一次创建一个对象并赋给一个变量时,引用计数器置为1。当对象被赋给任意变量时,引用计数器每次加1,当对象出了作用域后(该对象丢弃不再使用),引用计数器减1,一旦引用计数器为0,对象就满足了垃圾收集的条件。
唯一没有使用根可达性算法的垃圾回收算法。
缺陷:不能解决循环引用的回收。

tracing算法(tracing collector)

tracing算法是为了解决引用计数法的问题而提出,它使用了根集(GC Roots)概念。垃圾收集器从根集开始扫描,识别出哪些对象可达,哪些对象不可达,并用某种方式标记可达对象,例如对每个可达对象设置一个或多个位。在扫描识别过程中,基于tracing算法的垃圾收集也称为标记和清除(mark-and-sweep)垃圾收集器

compacting算法(Compacting Collector)

为了解决堆碎片问题,在清除的过程中,算法将所有的对象移到堆的一端,堆的另一端就变成了一个相邻的空闲内存区,收集器会对它移动的所有对象的所有引用进行更新,使得这些引用在新的位置能识别原来的对象。在基于Compacting算法的收集器的实现中,一般增加句柄和句柄表。

copying算法(Coping Collector)

该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它开始时把堆分成 一个对象面和多个空闲面,程序从对象面为对象分配空间,当对象满了,基于coping算法的垃圾收集就从根集中扫描活动对象,并将每个活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。

generation算法(Generational Collector) :现在的java内存分区

stop-and-copy垃圾收集器的一个缺陷是收集器必须复制所有的活动对象,这增加了程序等待时间,这是coping算法低效的原因。在程序设计中有这样的规律:多数对象存在的时间比较短,少数的存在时间比较长。因此,generation算法将堆分成两个或多个,每个子堆作为对象的一代 (generation)。由于多数对象存在的时间比较短,随着程序丢弃不使用的对象,垃圾收集器将从最年轻的子堆中收集这些对象。在分代式的垃圾收集器运行后,上次运行存活下来的对象移到下一最高代的子堆中,由于老一代的子堆不会经常被回收,因而节省了时间。

adaptive算法(Adaptive Collector)

在特定的情况下,一些垃圾收集算法会优于其它算法。基于Adaptive算法的垃圾收集器就是监控当前堆的使用情况,并将选择适当算法的垃圾收集器。

上一篇下一篇

猜你喜欢

热点阅读