java核心技术记录

本文记录了Java的一些核心技术和易混淆的概念

对Java平台的理解

Java本身是一种面向对象的语言,最显著的特性有两个方面:

  • “书写一次,到处运行”(Write once, run anywhere),能够非常容易地获得跨平台能力
  • 垃圾收集(GC, Garbage Collection),Java通过垃圾收集器(Garbage Collector)回收分配内存,大部分情况下,程序员不需要自己操心内存的分配和回收

我们日常会接触到 JRE(Java Runtime Environment)或 JDK(Java Development Kit)。JRE是Java运行环境,包含了JVM和Java类库,以及一些模块等。JDK是JRE的一个超集,提供了更多工具,比如编译器,各种诊断工具等。

我们开发的Java的源代码,首先通过Javac编译称为字节码(bytecode),然后,在运行时,通过Java虚拟机(JVM)内嵌的解释器将字节码转换成为最终的机器码。但是常见的JVM都提供了JIT(Just-In-Time)编译器,也就是常说的动态编译器,JIT能够在运行时将热点代码编译成机器码,这种情况下部分热点代码就属于编译执行,而不是解释执行了。

JVM的基础概念和机制

  • Java的类加载机制,常用版本JDK(如JDK8)内嵌的Class-Loader,如Bootstrap、Application和Extension Class-Loader
  • 类加载大致过程:加载、验证、链接、初始化
  • 自定义Class-Loader
  • 垃圾收集的基本原理,最常见的垃圾收集器,如SerialGC,ParallelGC,CMS,G1等,要清楚适用于什么样的工作负载
  • JDK包含的各种工具:编译器、运行时环境、安全工具、诊断和监控工具

Exception和Error的区别

Exception和Error都是继承了Throwable类,在Java中只有Throwable类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

Exception和Error体现了Java平台设计者对不同异常情况的分类。

  • Exception是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。
  • Error是指在正常情况下,不大可能出现的情况,绝大部分的Error都会导致程序(比如JVM自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如OutOfMemoryError之类,都是Error的子类。

Exception又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。前面说的不可查的Error,是Throwable不是Exception。

不检查异常就是所谓的运行时异常,类似NullPointerException、ArrayIndexOutOfBoundsException之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译器强制要求。

理解Throwable,Exception,Error的设计和分类。比如,掌握那些应用最为广泛的子类,以及如何自定义异常等。

其中有些子类型,要重点理解一下,比如
NoClassDefFoundError和ClassNotFoundException有什么区别,这也是一个经典的入门问题。

从性能角度来审视一下java的异常处理机制,这里有两个可能会相对昂贵的地方:

  • try-catch代码段会产生额外的性能开销,或者换个角度说,他往往会影响JVM对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的try包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else,switch)要低效
  • java每实例化一个Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常的频繁,这个开销可能就不能被忽略了。

final、finally、finalize的区别

  • final可以用来修饰类、方法、变量,分别有不同的意义,final修饰的class代表不可以继承扩展,final的变量是不可以修改的,而final的方法也是不可以重写的(override)
  • finally则是保证重点代码一定要被执行的一种机制。我们可以使用try-finally或者try-catch-finally来进行类似关闭JDBC连接、保证unlock锁等动作。
  • finalize是基础类java.lang.Object的一个方法,它的设计目的是保证对象在被垃圾收集前完成特定资源的回收。finalize机制现在已经不推荐使用,并在JDK 9 开始被标记为deprecated

可以发现Java核心类库的定义或源码下(java.lang包下的很多类)和第三方类库的一些基础类中相当一部分都被声明为final class,目的是为了有效避免API使用者更改基础功能,是保证平台安全的必要手段

  • 使用final修饰参数或者变量,也可以清楚地避免意外赋值导致的编程错误
  • final变量产生了某种程度的不可变(immutable)的效果(但是并不等同,例如声明为final的List仍然可以正常添加元素,对象行为不被final影响,只能约束List这个引用不可以被赋值),所以可以用于保护只读数据,尤其是在并发编程中,因为明确地不能再赋值final变量,有利于减少额外的同步开销,也可以省去一些防御性拷贝的必要

要实现immutable的类,我们需要做到:

  • 将class自身声明为final,这样别人就不能扩展来绕过限制了
  • 将所有成员变量定义为private和final,并且不要实现setter方法
  • 通常构造对象时,成员变量使用深度拷贝来初始化,而不是直接赋值,这是一种防御措施,因为无法确定输入对象不被其他人修改
  • 如果确实需要实现getter方法,或者其他可能会返回内部状态的方法,使用copy-on-write原则,创建私有的copy

强引用、软引用、弱引用、幻象引用的区别

在Java语言中,除了原始数据类型的变量,其他所有都是所谓的应用类型,指向各种不同的对象。

不同的引用类型,主要体现的是对象不同的可达性(reachable)状态和对垃圾收集的影响

  • 所谓强引用(“Strong” Reference),就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还“活着”,垃圾收集器不会碰这种对象。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以被垃圾收集的了,当然具体回收时机还是要看垃圾收集策略。
  • 软引用(SoftReference)是一种相对强引用弱化一些的应用,可以让对象豁免一些垃圾收集,只有当JVM认为内存不足时,才会去试图回收软引用指向的对象。JVM会确保在抛出OutOfMemoryError之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
  • 弱引用(WeakReference)并不能使对象豁免垃圾收集,仅仅是提供一种访问在弱引用状态下对象的途径。这就可以用来构建一种没有特定约束的关系,比如,维护一种非强制性的映射关系,如果试图获取时对象还在,就使用它,否则重现实例化。它同样是很多缓存实现的选择
  • 对于幻象引用,有时也翻译为虚引用,你不能通过它访问对象。幻象引用仅仅是提供了一种确保对象被finalize以后,做某些事情的机制,比如通常用来做所谓的Post-Mortem清理机制。

String、StringBuffer、StringBuilder的区别

  • String是Java语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型的Immutable类,被声明称为final class,所有属性也都是final的。也由于它的不可变性,类似拼接、裁剪字符串等动作,都会产生新的String对象。由于字符串操作的普遍性,所以相关操作的效率往往对应用性能有明显影响。
  • StringBuffer是为解决上面提到拼接产生太多中间对象的问题而提供的一个类,我们可以用append或者add方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer本质是一个线程安全的可修改字符序列,它保证了线程安全,也随之带来了额外的性能开销,所以除非有线程安全的需要,不然还是推荐使用它的后继者,也就是StringBuilder
  • StringBuilder是Java1.5中新增的,在能力上和StringBuffer没有本质区别,但是它去掉了线程安全的部分,有效减小了开销,是绝大部分情况下进行字符串拼接的首选

反射机制、动态代理基于的原理

编程语言通常有各种不同的分类角度,动态类型和静态类型是其中一种分类角度,简单区分就是语言类型信息是在运行时检查,还是编译期检查

与其近似的还有一个对比,就是所谓强类型和弱类型,就是不同类型变量赋值时,是否需要显式地(强制)进行类型转换

通常认为Java是静态的强类型语言,但是因为提供了类似反射等机制,也具备了部分动态类型语言的能力

  • 反射机制是Java语言提供的一种基础功能,赋予程序在运行时自省(introspect)的能力。通过反射我们可以直接操作类或对象,比如获取某个对象的类定义,获取类声明的属性和方法,调用方法或者构造对象,设置可以运行时修改类定义

    • Class.forName(“XXX”)会进行类加载,如果类中有static方法,也会在此时执行
    • Class.forName(“XXX”).newInstance()实例化一个对象,newInstance只能是无参构造
  • 动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类似机制做到的,比如用来包装RPC调用、面向切面的编程(AOP)

  • 实现动态代理的方式很多,比如JDK自身提供的动态代理,就是主要利用了上面提到的反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似ASM、cglib(基于ASM)、Javassist等

动态代理到底解决什么问题?

  • 首先它是一个代理机制,如果熟悉设计模式中的代理模式,我们会知道,代理可以看做是对调用目标的一个包装,这样我们对目标代码的调用不是直接发生的,而是通过代理完成。其实很多动态代理场景,也可以看做是装饰器(Decorator)模式的应用。
  • 通过代理可以让调用者和实现者之间解耦,比如进行RPC调用,框架内部的寻址、序列化、反序列化等,对于调用者往往是没有太大意义的,通过代理,可以提供更加友善的界面

int和Integer的区别

  • int是我们常说的整型数字,是Java的8个原始数据类型(Primitive Types, boolean、byte、short、char、int、float、double、long)之一。Java语言虽然号称一切都是对象,但是原始数据类型是例外
  • Integer是int对应的包装类,它有一个int类型的字段存储数据,并且提供了基本操作,比如数学运算、int和字符串之间转换等。在Java5中,引入了自动装箱和自动拆箱功能(boxing/unboxing)功能,Java可以根据上下文,自动进行转换,极大地简化了相关编程
  • 关于Integer的值缓存,这设计Java5中另一个改进。构建Integer对象的传统方式是直接调用构造器,直接new一个对象。但是根据时间,我们发现大部分数据操作都是几种在有限的、较小的数值范围,因而在Java5中新增了静态工厂方法valueOf,在调用它的时候会利用一个缓存机制,带来了明显的性能改进。按照Javadoc,这个值默认缓存是-128到127之间(使用缓存中包含的实例对象,而不是创建一个新的实例(在自动装箱的情况下)可以提高性能)

理解自动装箱、拆箱:

  • 自动装箱实际上算是一种语法糖(可以简单理解为Java平台为我们自动进行了一些转换,保证不同的写法在运行时等价,它们发生在编译阶段,也就是生成的字节码是一致的)
  • 比如前面提到的整数,javac替我们自动把装箱转换为Integer.valueOf(),把拆箱替换为Integer.intValue(),由于调用的是 valueOf(),自然能够得到缓存的好处。其他一些包装类中也存在缓存机制:
    • Boolean,缓存了true/false对应实例,确切说,只会返回两个常量实例Boolean.TRUE/FALSE
    • Short,同样是缓存了-128到127之间的数值
    • Byte,数值有限,所以全部被缓存
    • Character,缓存范围‘u0000’到‘u007F’
  • 实际编程中使用自动装箱/自动拆箱的注意事项:
    • 原则上建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,创建10万个Java对象和10万个整数的开销不是一个数量级的,不管是内存使用还是处理速度,光是对象头的空间占用就已经是数量级的差距了
    • 将这个观点扩展开可以得到:使用原始数据类型、数组甚至本地代码实现等,在性能极度敏感的场景往往具有比较大的优势,用其替换掉包装类、动态数组(如ArrayList)等可以作为性能优化的备选项。(但是开发效率可能会降低)

Java原始数据类型和引用类型局限性

对于Java应用开发者,设计复杂而灵活的类型系统似乎已经习以为常了,但是这种设计是源于很多年前的技术决定,现在已经逐渐暴露出了一些副作用,例如:

  • 原始数据类型和Java泛型并不能配合使用

    这是因为Java的泛型某种程度上可以算作伪泛型,它完全是一种编译期的技巧,Java编译期会自动将类型转换为对应的特定类型,这就决定了使用泛型,必须保证相应类型可以转换为Object

  • 无法高效地表达数据,也不便于表达复杂的数据结构,比如vector和tuple

    我们知道Java的对象都是引用类型,如果是一个原始数据类型数组,它在内存里是一段连续的内存,而对象数组则不然,数据存储的是引用,对象往往是分散地存储在堆的不同位置。这种设计虽然带来了极大灵活性,但是也导致了数据操作的低效,尤其是无法充分利用现代CPU缓存机制

Vector、ArrayList、LinkedList的区别

这三者都是实现集合框架中的List,也就是所谓的有序集合,因此具体功能也比较近似,比如都提供按照位置进行定位、添加或者删除的操作,都提供迭代器以遍历其内容等。但因为具体的设计区别,在行为、性能、线程安全等方面,表现又有很大不同

  • Vector是Java早期提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的。Vector内部是使用对象数组来保存数据,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
  • ArrayList是应用更加广泛的动态数组实现,它本身不是线程安全的,所以性能要好很多。与Vector近似,ArrayList也是可以根据需要调整容量,不过两者的调整逻辑有所区别,Vector在扩容时会提高1倍,而ArrayList则是增加50%
  • LinkedList顾名思义是Java提供的双向链表,所以它不需要像上面两种那样调整容量,它也不是线程安全的

补充不同容器类型适合的场景:

  • Vector和ArrayList作为动态数组,其内部元素以数组形式顺序存储的,所以非常适合随机访问的场合。除了尾部插入和删除元素,往往性能会相对较差,比如我们在中间位置插入一个元素,需要移动后续所有元素。
  • 而LinkedList进行节点插入、删除却要高效得多,但是随机访问性能则要比动态数组慢

所以在应用开发中,如果实现可以顾及到,应用操作是偏向于插入、删除,还是随机访问较多,就可以针对性的进行选择。

集合框架图:

Hashtable、HashMap、TreeMap的区别

Hashtable、HashMap、TreeMap都是常见的一些Map实现,是以键值对的形式存储和操作数据的容器类型

  • Hashtable是早期Java类库提供的一个哈希表实现,本身是同步的,不支持null键和值,由于同步导致的性能开销,所以已经很少被推荐使用。
  • HashMap是应用更加广泛的哈希表实现,行为上大致上与HashTable一致,主要区别在与HashMap不是同步的,支持null键和值等。通常情况下,HashMap进行put或者get操作,可以达到常数时间的性能,所以它是绝大部分利用键值对存取场景的首选,比如,实现一个ID和用户信息对应的运行时存储结构,
  • TreeMap则是基于红黑树的一种提供顺序访问的Map,和HashMap不同,它的get、put、remove之类的操作都是O(log(n))的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序来判断

HashMap内部结构分析

HashMap可以看做是数组(Node[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对,则以链表形式存储。参考如下示意图,注意当链表大小超过阈值(TREEIFY_THRESHOLD,8),链表就会被改造为树形结构。

从非拷贝构造函数的实现来看,这个表格(数组)似乎并没有在最初就初始化好,仅仅设置了一些初始值而已:

1
2
3
4
5
6
7
8
9
10
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}

所以我们深刻怀疑,HashMap也许是按照lazy-load原则,在首次使用时被初始化(拷贝构造函数除外,这里仅介绍最通用的场景)。既然如此,去看看put方法实现,似乎只有一个putVal的调用:

1
2
3
public V put(K key, V value){
return putVal(hash(key), key, value, false, true);
}

截取putVal中比较关键的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//...
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
//...
}

可以发现:

  • 如果表格是null,resize方法会负责初始化它,这从tab = resize()可以看出

  • resize方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)

  • 在放置新的键值对的过程中,如果发生下面条件,就会发生扩容

    1
    2
    if (++size > threshold)
    resize();
  • 具体键值对在哈希表中的位置(数组index)取决于下面的位运算:

    1
    i = (n - 1) & hash

    仔细观察哈希值的源头,我们会发现,它并不是key本身的hashCode,而是来自于HashMap内部的另一个hash方法。注意为什么这里需要将高位数据移位到低位进行异或运算呢?这是因为有些数据计算出的哈希值差异主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞

    1
    2
    3
    4
    static final int hash(Object key){
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

进一步分析一下身兼多职的resize方法:

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
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//......移动到新的数组结构e数组结构

return newTab;
}

依据resize源码,不考虑极端情况(容量理论最大极限由MAXIMUM_CAPACITY指定,数值为1<<30,也就是2的30次方),我们可以归纳为:

  • 门限值等于(负载因子)*(容量),如果苟江HashMap的时候没有指定它们,那么就是依据相应的默认常量值。
  • 门限通常是以倍数进行调整(newThr = oldThr << 1),前面提到,根据putVal中的逻辑,当元素个数超过门限大小时,则调整Map大小
  • 扩容后,需要将老的数组中的元素重新房知道新的数组,这是扩容的一个主要开销来源

容量、负载因子和树化

为什么我们需要在乎容量和负载因子呢?

  • 这是因为容量和负载系数决定了可用的桶的数量,空桶太多会浪费空间,如果使用的太满则会严重影响操作的性能。极端情况下,假设只有一个桶,那么它就退化成了链表,完全不能提供所谓常数时间存的性能。

在实践中应该如何选择呢?

  • 如果能够知道HashMap要存取的键值对数量,可以考虑预先设置合适的容量大小,具体数值可以通过 负载因子 * 容量 > 元素数量 来预估

对于负载因子的建议:

  • 如果没有特别需求,不要轻易进行更改,因为JDK自身 的默认负载因子是非常符合通用场景的需求的
  • 如果确实需要调整,建议不要设置超过0.75的数值,因为会显著增加冲突,降低HashMap的性能
  • 如果使用太小的负载因子,按照公式,预设容量值也进行调整,否则可能会导致更加频繁的扩容,增加无谓的开销,本身访问性能也会受影响

关于树化改造,对应逻辑主要在putVal和treeifyBin中,那么为什么要进行树化呢?

  • 本质上是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道连表查询是线性的,会严重影响存取的性能。
  • 而实际上,构造哈希冲突的数据并不是非常复杂的事情,恶意代码就可以利用这些数据大量与服务器端交互,导致服务端CPU大量占用,这就构成了哈希碰撞拒绝服务攻击

如何保证集合是线程安全的

Java提供了不同层面的线程安全支持。在传统集合框架内部,除了HashTable等同步容器,还提供了所谓的同步包装器(Synchronized Wrapper),我们可以调用Collections工具类提供的包装方法,来获取一个同步的包装容器(如Collections.synchronizedMap),但是它们都是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。

另外,更加普遍的选择是利用并发包提供的线程安全容器类,它提供了:

  • 各种并发容器,比如ConcurrentHashMap、CopyOnWriteArrayList
  • 各种线程安全队列(Queue/Deque),如ArrayBlockingQueue、SynchronousQueue
  • 各种有序容器的线程安全版本等

具体保证线程安全的方式,包括从简单的synchronize方式,到基于更加精细化的,比如基于分离锁实现的ConcurrentHashMap等并发实现等。具体喧闹着要看开发的场景需求,总体来说,并发包内提供的容器通用场景,远优于早期的简单同步实现。

Java提供的IO方式

Java IO方式有很多种,基于不同的IO抽象模型和交互方式,可以进行简单区分

  • 首先,传统的java.io包,它基于流模型实现,提供了我们最熟知的一些IO功能,比如File抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在哪里,它们之间的调用是可靠的线性顺序。

    java.io包的好处是代码比较简单、直观,缺点则是IO效率和扩展性存在局限性,容易称为应用性能的瓶颈。

    很多时候,人们也把java.net下面提供的部分网络API,比如Socket、ServerSocket、HttpURLConnection也归类到同步阻塞IO类库,因为网络通信同样是IO行为

  • 第二,在Java 1.4中引入了NIO框架(java.nio包),提供了Channel、Selector、Buffer等新的抽象,可以构建多路复用的、同步非阻塞IO程序,同时提供了更接近操作系统底层的高性能数据操作方式

  • 第三,在Java 7中,NIO有了进一步的改进,也就是NIO 2,引入了异步非阻塞IO方式,也有很多人叫它AIO(Asychronous IO)。异步IO操作基于时间和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在哪里,当后台处理完成,操作系统会通知相应线程进行后续工作。

基本概念解释:

  • 区分同步或异步(synchronous/asynchronous)。简单来说,同步是一种可靠的有序运行机制,当我们进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系
  • 区分阻塞与非阻塞(blocking/non-blocking)。在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如ServerSocket新连接建立完毕,或数据读取、写入操作完成;而非阻塞则是不管IO操作是否结束,直接返回,相应操作在后台继续处理

不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征

对于java.io:

  • IO不仅仅是对文件的操作,网络编程中,比如Socket通信,都是典型的IO操作目标
  • 输入流、输出流(InputStream/OutputStream)是用于读取或写入字节的,例如操作图片文件
  • 而Reader/Writer则是用于操作字符,增加了字符编解码等功能,适用于类似从文件中读取或者写入文本信息。本质上计算机操作的都是字节,不管是网络通信还是文件读取,Reader/Writer相当于构建了应用逻辑和原始数据之间的桥梁
  • BufferedOutputStream等带缓冲区的实现,可以避免频繁的磁盘读写,进而提高IO处理效率。这种设计利用了缓冲区,将批量数据进行一次操作,但在使用中千万别忘了flush

Java有几种文件拷贝方式?

Java有多种比较典型的文件拷贝实现方式:

  • 利用java.io类库,直接为源文件构建一个FileInputStream读取,然后再为目标文件构建一个FileOutputStream,完成写入工作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static void copyFileByStream(File source, File dest) throws IOException{
    try{
    InputStream is = new FileInputStream(source);
    byte[] buffer = new byte[1024];
    int length;
    while((length = is.read(buffer)) > 0){
    os.write(buffer, 0, length);
    }
    }
    }
  • 利用java.nio类库提供的transferTo或tansferFrom方法实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public static void copyFileByChannel(File source, File dest) throws IOException{
    try{
    FileChannel sourceChannel = new FileInputStream(source).getChannel();
    FileChannel targetChannel = new FileOutputStream(dest).getChannel();
    for(long count = sourceChannel.size(); count>0; ){
    long transferred = sourceChannel.transferTo(sourceChannel.position(),count,targetChannel);
    sourceChannel.position(sourceChannel.position + transferred);
    count -= transferred;
    }
    }
    }
  • 当然,Java标准类库本身已经提供了几种Files.copy的实现

对于Copy的效率,这个其实与操作系统和配置等情况相关,总体上来说,NIO transferTo/From的方式可能更快,因为它更能利用现代操作系统底层机制,避免不必要拷贝和上下文切换。

接口和抽象类的区别

接口和抽象类是Java面向对象设计的两个基础机制

  • 接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到API定义和实现分离的目的。接口不能实例化;不能包含任何非常量成员,任何field都是隐含着public static final的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。Java标准类库中定义了非常多的接口,比如java.util.List

  • 抽象类识不能实例化的类,用abstract关键字修饰class,其目的主要是代码重用。除了不能实例化,形式上和一般的Java类并没有太大区别,可以有一个或多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关Java类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。Java标准库中,比如collection框架,很多通用部分就抽取称为抽象类,例如java.util.AbstractList。

  • Java类实现interface使用implements关键词,继承abstract class则是使用extends关键词,可以参考Java标准库中的ArrayList:

    1
    2
    3
    public class ArrayList extends AbstractList implements List, RandomAccess, Cloneable, java.io.Serializable{
    //...
    }

知识扩展

  • Java相比于其他面向对象语言,如C++,这几上有一些基本区别,比如Java不支持多继承。这种限制,在规范了代码实现的同时,也产生了一些局限性,影响着程序设计结构。Java类可以实现多个接口,因为接口是抽象方法的集合,所以这是声明性的,但不能通过扩展多个抽象类来重用逻辑
  • 在一些情况下存在特定场景,需要抽象出与具体实现、实例化无关的通用逻辑,或者纯调用关系的逻辑,但是使用传统的抽象类会陷入到单继承的窘境。以往常见的做法是,实现由静态方法组成的工具类(Utils),比如java.util.Collections。
  • 为接口添加任何抽象方法,相应的所有实现了这个接口的类,也必须实现新增方法,否则会出现编译错误。对于抽象类,如果我们添加非抽象方法,其子类只会享受到能力扩展,而不用担心编译出问题。
  • 接口的职责也不仅仅限于抽象方法的集合,其实有各种不同的实践。有一类没有任何方法的接口,通常叫做Marker Interface,顾名思义,它的目的就是为了声明某些东西,比如我们熟知的Cloneable、Serializable等。这种用法,也存在于业界其他的Java产品代码中。
  • 表面上看这似乎与Annotation异曲同工,也确实如此,它的好处是简单直接。对于Annotation,因为可以指定参数和值,在表达能力上要更强大一些,所以更多人选择使用Annotation。
  • Java 8增加了函数式编程的支持,所以又增加了一类定义,即所谓functional interface,简单说就是只有一个抽象方法的接口,通常建议使用@FunctionalInterface Annotation来标记。Lambda表达式本身可以看作是一类functional interface,某种程度上这和面向对象是两码事。常见的Runnable、Callable之类,都是functional interface

面向对象设计

面向对象的基本要素:封装、继承、多态

  • 封装的目的是隐藏事务内部的实现细节,以提高安全性和简化编程。封装提供了合理的边界,避免外部调用者接触到内部的细节。我们在日常开发中,因为无意间暴露了细节导致的难缠bug太多了,比如在多线程环境暴露内部状态,导致的并发修改问题。从另外一个角度看,封装这种隐藏,也提供了简化的界面,避免太多无意义的细节浪费调用者的精力。
  • 继承是代码复用的基础机制,类似于我们对于马、白马、黑马的归纳总结。但要注意,继承可以看作是非常紧耦合的一种关系,父类代码修改,子类行为也会变动。在实践中,过度滥用继承,可能会起到反效果。
  • 多态,可能立即会想到重写(override)和重载(overload)、向上转型。简单说,重写是父子类中相同名字和参数的方法,不同的实现;重载则是相同名字的方法,但是不同的参数,本质上这些方法签名是不一样的。(注意只有返回类型不同的重载不是有效的重载,编译会出错)

面向对象S.O.L.I.D设计原则:

  • 单一职责(Single Responsibility),类或者对象最好只有单一职责,在程序设计中如果发现某个类承担着多种义务,可以考虑进行拆分。

  • 开关原则(Open-Close,Open for extension,close for modification),设计要对扩展开放,对修改关闭。换句话说,程序设计应保证平滑的扩展性,尽量避免因为新增同类功能而修改已有实现,这样可以少产出些回归(regression)问题。

  • 里氏替换(Liskov Substitution),这是面向对象的基本要素之一,进行继承关系抽象时,范式可以用父类或者基类的地方,都可以用子类替换。

  • 接口分离(Interface Segregation),我们在进行类和接口设计时,如果在一个接口里定义了太多方法,其子类很可能面临两难,就是只有部分方法对它是有意义的,这就破坏了程序的内聚性。

    对于这种情况,可以通过拆分成功能单一的多个接口,将行为进行解耦。在未来维护中,如果某个接口设计有变,不会对使用其他接口的子类构成影响。

  • 依赖反转(Dependency Inversion),实体应该依赖于抽象而不是实现(要针对接口编程而不是对实现编程)。也就是说高层次模块,不应该依赖于低层次模块,而是应该基于抽象。实践这一原则是保证产品代码之间适当耦合度的法宝。

设计模式相关

大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构性模式和行为型模式

  • 创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式(Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模式(ProtoType)
  • 结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式,包括桥接模式(Bridge)、适配器模式(Adaptor)、装饰者模式(Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)等
  • 行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。比较常见的行为型模式有策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)。

实现一个单例模式

最简单的方式就是为单例定义一个private的构造函数。

另外可以通过标准类库中很多地方使用的懒加载(lazy-load)改善初始内存开销:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton{
// 如果没有使用懒加载,会在第一次加载类的时候会连带着创建 Singleton 实例,但实际上可能暂时用不到,则浪费了资源
// private static Singleton instance = new Singleton();
private static Singleton instance;
private Singleton(){
}
//使用了懒加载机制,在使用的时候才去创建。
public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
}

这个实现在单线程环境不存在问题,但是如果处于并发场景,就需要考虑线程安全,最熟悉的莫过于“双检锁”,其要点在于:

  • 这里的volatile能够提供可见性,以及保证getInstance返回的是初始化完全的对象
  • 在同步之前进行null检查,以尽量避免进入相对昂贵的同步块
  • 直接在class级别进行同步,保证线程安全的类方法调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton{
private static volatile Singleton singleton = null;
private Singleton(){
}

public static Singleton getSingleton(){
if(singleton == null){//尽量避免重复进入同步块
synchronized(Singleton.class){//同步.class,意味着对同步类方法调用
if(singleton = null){
singleton = new Singleton();
}
}
}
return singleton;
}
}

另外也有人推荐利用内部类持有静态对象的方式实现,其理论依据是对象初始化过程中隐含的初始化锁,不过语法稍显灰色,未必有特别的优势

1
2
3
4
5
6
7
8
9
10
public class Singleton{
private Singleton(){}
public static Singleton getSingleton(){
return Holder.singleton;
}

private static class Holder{
private static Singleton singleton = new Singleton();
}
}

Spring在API设计中使用的设计模式:

  • BeanFactory和ApplicationContext应用了工厂模式
  • 在Bean的创建中,Spring也为不同scope定义的对象,提供了单例和原型等模式实现
  • AOP领域使用了代理模式、装饰器模式、适配器模式等
  • 各种事件监听器,是观察者模式的典型应用
  • 类似JdbcTemplate等则是应用了模板模式

synchronized和ReentrantLock的区别

synchronized是Java内建的同步机制,也有人称其为Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者在阻塞在那里。

在Java 5以前,synchronized是仅有的同步手段,在代码中,synchronized可以用来修饰方法,也可以使用在特定的代码块上,本质上synchronized方法等同于把方法全部语句用synchronized块包起来。synchronized(ClassName.class) {}

ReentrantLock,通常翻译为再入锁,是Java 5提供的锁实现,它的语义和synchronized基本相同。再入锁通过代码直接调用lock()方法获取,代码书写也更加灵活。与此同时,ReentrantLock提供了很多实用的方法,能够实现很多synchronized无法做到的细节控制,比如可以控制fairness,也就是公平性,或者利用定义条件等。但是,编码中也需要注意,必须要明确调用unlock()方法释放,不然就会一直持有该锁。

理解什么是线程安全

线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性,这里的状态反映在程序中其实可以看作是数据。

换个角度看,如果状态不是共享的,或者不是可修改的,也就不存在线程安全问题,进而可以推理出保证线程安全的两个办法:

  • 封装:通过封装,我们可以将对象内部状态隐藏、保护起来
  • 不可变:final和immutable。Java语言目前还没有真正意义上的原生不可变。

线程安全需要保证几个基本特性:

  • 原子性:简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现
  • 可见性:是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile就是负责保证可见性的
  • 有序性:保证线程内串行语义,避免指令重排

一个线程两次调用start()方法会发生什么?

Java的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。

关于线程生命周期的不同状态,在Java5以后,线程状态被明确定义在其公共内部枚举类型java.lang.Thread.State中,分别是:

  • 新建(NEW),表示线程被创建出来还没真正启动的状态,可以认为它是个Java内部状态

  • 就绪(RUNNABLE),表示该线程已经在JVM中执行,当然由于执行需要计算资源,它可能是正在运行,也可能还在等待系统分配给它CPU片段,在就绪队列里面排队

  • 在其他一些分析中,会额外区分一种状态RUNNING,但是从Java API的角度,并不能表示出来

  • 阻塞(BLOCKED),这个状态和之前介绍的同步非常相关,阻塞表示线程在等待Monitor lock。比如,线程试图通过synchronized去获取某个锁,但是其他线程已经独占了,那么当前线程就会处于阻塞状态。

  • 等待(WAITING),表示正在等待其他线程采取某些操作。一个常见的场景是类似生产者消费者模式,发现任务条件上位满足,就让当前消费者线程等待(wait),另外的生产者线程去准备任务数据,然后通过类似notify等动作,通知消费线程可以继续工作了。Thread.join()也会令线程进入等待状态

  • 计时等待(TIMED_WAIT),其进入条件和等待状态类似,但是调用的是存在超时条件的方法,比如wait或join等方法的指定超时版本,如:

    1
    public final native void wait(long timeout) throws InterruptedException;
  • 终止(TERMINATED),不管是意外退出还是正常执行结束,线程已经完成使命,终止运行,也有人把这个状态叫作死亡

在第二次调用start()方法的时候,线程可能处于终止或者其他(非NEW)状态,但是不论如何,都是不可以再次启动的

线程是什么?

从操作系统的角度,可以简单认为,线程是系统调度的最小单元,一个进程可以包含多个线程,作为任务的真正运作者,有自己的栈(Stack)、寄存器(Register)、本地存储(Thread Local)等,但是会和进程内其他线程共享文件描述符、虚拟地址空间等。

在具体实现中,线程还分为内核线程、用户线程,Java的线程实现是和虚拟机相关的。对于Sun/Oracle JDK,在Java1.2之后,已经抛弃了所谓的Green Thread,也就是用户调度的线程,现在的模型是一对一映射到操作系统内核线程。

创建线程的基本操作:

我们可以直接扩展Thread类,然后实例化。但是这里选择了另一种方式,就是实现一个Runnable,将代码逻辑放在Runnable中,然后构建Thread并启动(start),等待结束(join)。

1
2
3
4
5
6
7
8
9
10
public class ThreadTest {
public static void main(String[] args) throws InterruptedException {
Runnable task = ()->{
System.out.println("Hello World!");
};
Thread myThread = new Thread(task);
myThread.start();
myThread.join();
}
}

Runnable的好处是,不会受Java不支持多继承的限制,重用代码实现,当我们需要重复执行相应逻辑时优点明显。而且也能更好的与现代Java并发库中的Executor之类框架结合使用,比如将上面的start和join的逻辑转换成下面这种结构。这样我们就不用操心线程的创建和管理,也能利用Future等机制更好地处理执行结果。

1
2
3
4
5
6
7
8
public class ThreadTest {
public static void main(String[] args) throws InterruptedException, ExecutionException {
Runnable task = ()->{
System.out.println("Hello World!");
};
Future future = (Future) Executors.newFixedThreadPool(1).submit(task).get();
}
}

线程API的使用:

  • 守护线程(Daemon Thread),有的时候应用中需要一个长期驻留的服务程序,但是不希望其影响应用退出,就可以将其设置为守护线程,如果JVM发现只有守护线程 存在时,将结束进程。注意必须在线程启动之前设置!

    1
    2
    3
    Thread daemonThread = new Thread();
    daemonThread.setDaemon(true);
    daemonThread.start();
  • Spurious wakeup。尤其是在多核CPU系统中,线程等待存在一种可能,就是在没有任何线程广播或者发出信号的情况下,线程就被唤醒,如果处理不当就可能出现诡异的并发问题,所以我们在等待条件过程中,建议如下书写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //推荐
    while( isCondition()){
    waitForAConfition(...);
    }

    //不推荐,可能引入bug
    if( isCondition()){
    waitForAConfition(...);
    }
  • 慎用ThreadLocal

什么情况下Java程序会产生死锁

死锁是一种特定的程序状态,在实体之间,由于依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态

以下是一个基本的死锁程序:

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
public class DeadLockTest extends Thread{
private String first;
private String second;
public DeadLockTest(String name, String first,String second){
super(name);
this.first = first;
this.second = second;
}

public void run(){
synchronized (first){
System.out.println(this.getName()+" obtained: "+ first);
try{
Thread.sleep(1000L);
synchronized (second){
System.out.println(this.getName()+" obtained: "+second);
}
}catch (InterruptedException e){

}
}
}

public static void main(String[] args) throws InterruptedException {
String lockA = "lockA";
String lockB = "lockB";
DeadLockTest t1 = new DeadLockTest("Thread1",lockA,lockB);
DeadLockTest t2 = new DeadLockTest("Thread2",lockB,lockA);
t1.start();
t2.start();
t1.join();
t2.join();
}
}

这个程序编译执行后,几乎每次都可以重现死锁。这里查看输出还有一个有意思的地方:我先调用的是Thread1的start,但是Thread2却先打印出来了,这是因为线程调度依赖于(操作系统)调度器,虽然可以通过优先级之类进行影响,但是具体情况是不确定的。

要定位死锁,可以通过jstack、JConsole等工具进行查找,过程大概可总结为:

  • 区分线程状态 =》查看等待目标=》对比Monitor等持有状态

如何在编程中尽量预防死锁?

首先总结死锁发生的原因:

  • 互斥条件,类似Java中Monitor都是独占的
  • 互斥条件是长期持有的,在使用结束之前,自己不会释放,也不能被其他线程抢占
  • 循环依赖关系,两个或多个个体之间出现了锁的链条环

因此可以总结出避免死锁的思路和方法:

  • 如果可能的话,尽量避免使用多个锁,并且只有需要时才持有锁(嵌套的synchronized或lock非常容易出问题)

  • 如果必须使用多个锁,尽量设计好锁的获取顺序(参看银行家算法),一般情况可以采取些简单的辅助手段:

    • 将对象(方法)和锁之间的关系,用图形化的方式表示分别抽取出来
    • 然后根据对象之间组合、调用的关系对比和组合,考虑可能调用时序
    • 按照可能时序合并,发现可能死锁的场景
  • 使用带超时的方法,为程序带来更多可控性:

    • 类似Object.wait(…)或者CountDownLatch.await(…),都支持所谓的timed_wait,我们完全可以就不假定该锁一定会获得,指定超时时间,并为无法得到锁时准备退出逻辑

    • 并发Lock实现,如ReentrantLock还支持非阻塞式的获取锁操作tryLock(),这是一个插队行为(barging),并不在乎等待的公平性,如果执行时对象恰好没有被独占,则直接获取锁。有时,我们希望条件条件允许就尝试插队,不然就按照现有公平性规则等待,一般采用下面的方法:

      1
      2
      3
      if(lock.tryLock() || lock.tryLock(timeout, unit)){
      //...
      }

Java并发包提供的工具类

通常所说的并发包也就是java.util.concurrent及其子包,集中了Java并发的各种基础工具类,具体主要包括几个方面:

  • 提供了比synchronized更加高级的各种同步结构,包括CountDownLatch、CyclicBarrier、Semaphore等,可以实现更加丰富的多线程操作,比如利用Semaphore作为资源控制器,限制同时进行工作的线程数量

    • CountDownLatch,允许一个或多个线程等待某些操作完成
    • CyclicBarrier,一种辅助性的同步结构,允许多个线程等待到达某个屏障
    • Semaphore,Java版本的信号量实现
  • 各种线程安全的容器,比如最常见的ConcurrentHashMap、有序的ConcurrentSkipListMap,或者通过类似快照机制,实现线程安全的动态数组CopyOnWriteArrayList等

  • 各种并发队列实现,如各种BlockingQueue实现,比较典型的ArrayBlockingQueue、SynchronousQueue或针对特定场景的PriorityBlockingQueue等

  • 强大的Executor框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器。通常开发者都是利用Executors提供的通用线程池创建方法,去创建不同配置的线程池,主要区别在于不同的ExecutorService类型或者不同的初始参数。

    Executors目前提供了5种不同的线程池创建配置:

    • newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,具有几个鲜明特点:它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过60秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用SynchronousQueue作为工作队列
    • newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有nThreads个工作线程是活动的。这意味着,如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目nThreads。
    • newSingleThreadExecutor(),它的特点在与工作线程数目被限制为1,操作一个无界的工作队列,所以它保证了所有任务都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目
    • newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize),创建的是个ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在与单一工作线程还是多个工作线程。
    • newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序

AtomicInteger底层实现原理和如何自己应用CAS操作

AtomicIntger是对int类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAScompare-and-swap)技术。

所谓CAS,表征的是一些列操作的集合,获取当前数值,进行一些运算,利用CAS指令试图进行更新。如果当前数值未变,代表没有其他线程进行并发修改,则成功更新。否则,可能出现不同的选择,要么进行重试,要么就返回一个成功或者失败的结果。

从AtomicInteger的内部属性可以看出,它依赖于Unsafe提供的一些底层能力,进行底层操作以volatile的value字段,记录数值,以保证可见性

1
2
3
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
private volatile int value;

具体的原子操作细节,可以参考任意一个原子更新方法,比如下面的getAndIncrement。

Unsafe会利用value字段的内存地址偏移,直接完成操作。

1
2
3
public final int getAndIncrement() {    
return U.getAndAddInt(this, VALUE, 1);
}

因为getAndIncrement需要返归数值,所以需要添加失败重试逻辑。

1
2
3
4
5
6
7
public final int getAndAddInt(Object o, long offset, int delta) {    
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}

而类似compareAndSet这种返回boolean类型的函数,因为其返回值表现的就是成功与否,所以不需要重试。

1
public final boolean compareAndSet(int expectedValue, int newValue)

CAS是Java并发中所谓lock-free机制的基础。

Java类加载的过程

一般来说,把Java的类加载过程分为三个主要步骤:加载、链接、初始化

  • 加载阶段(Loading),它是Java将字节码数据从不同的数据源读取到JVM中,并映射为JVM认可的数据结构(Class对象),这里的数据源可能是各种各样的形态,如jar文件、class文件,甚至是网络数据源等;如果输入数据不是ClassFile结构,则会抛出ClassFormatError。

    加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程

  • 链接(Linking),这是核心的步骤,简单说是把原始的类定义信息平滑地转化如JVM运行的过程中。这里可进一步细分为三个步骤:

    • 验证(Verification),这是虚拟机安全的重要保障,JVM需要核验字节信息是符合Java虚拟机规范的,否则就被认为是VerifyError,这样就防止了恶意信息或者不合规信息危害JVM的运行,验证阶段有可能触发更多class的加载
    • 准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在与分配所需要的内存空间,不会去执行更进一步的JVM指令
    • 解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。
  • 初始化阶段(Initialization),这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优于当前类型的逻辑

双亲委派模型:

简单来说就是当类加载器(Class-Loader)试图加载某个类型的时候,除非父加载器找不到相应类型,否则尽量将这个任务代理给当前加载器的父加载器去做。使用委派模型的目的是避免重复加载Java类型

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022 ZHU
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信