JavaSE 面试基础知识总结

"Java core knowledge"

Posted by Ming on July 9, 2019

“There are no secrets to success. It is the result of preparation, hard work and learning from failure.”

1. 编译型语言与解释性语言的概念和区别

首先,我们实际上大部分编程都是用高级语言,计算机不能直接理解高级语言,只能够理解和运行机器语言,所以必须要把高级语言翻译成机器语言,计算机才能运行高级语言所编写的程序。说到翻译,其实翻译的方式有两种:一种是编译,一种是解释。两种方式只是翻译的时间不同。

1.1 编译型语言

概念:程序(源程序)在执行之前需要编译过程,把程序编译(通过编译系统把高级语言进行翻译为机器语言)成为机器语言的文件,运行时不需要重新编译,直接使用编译的结果。给出一个用C编写的程序hello.c的编译过程:

编译过程

一般需要经过编译、链接这两个步骤。编译是指将源代码编译成机器码,而链接是把各个模块的机器码(包括标准库)链接起来生成可执行文件。(其过程可分为四个阶段:预处理阶段,编译阶段,汇编阶段,链接阶段)

优点:编译只做一次,运行时不需要编译,所以编译型语言的程序执行效率高(但是也不能一概而论,因为部分解释型语言的解释器在运行时能动态优化代码,甚至使得解释型语言的性能不低于编译型语言)。所以像开发操作系统,大型应用程序,数据库系统等时都采用它,比如C++/C、Pascal/Obejct Pascal 等都是编译语言。

缺点:编译之后如果需要修改就需要将整个模块重新编译。编译时会根据运行环境生成对应的机器码,不同操作系统之间移植较困难。目标程序不具有移植性,源程序可以移植。

1.2 解释型语言

概念: 解释型语言是相对于编译性语言存在,源程序不需要编译,源程序在运行时才翻译成机器语言,每执行一次需要翻译一次。先翻译成中间代码,由解释器运行(中间代码与机器代码是不同的,用解释型语言编写的程序是由另一个可以理解中间代码的解释程序执行的。与编译程序不同的是,解释器的任务是逐一将源程序的语句解释成可执行的机器代码,不需要将源程序翻译成目标代码再执行)。

优点:有良好的平台兼容性,在任何环境下都可以运行,只要安装了解释器。修改代码时直接修改即可,可以快速部署,不用停机维护。一些网页脚本,服务器脚本以及辅助开发接口这样的对素对要求不高、对不同系统平台间的兼容性有一定要求的程序则通常使用解释型语言,如JavaScript,VBScript,Perl,Python,Ruby,MATLAB等。

缺点:每次运行的时候都要解释一遍,性能上相较于编译型语言逊色。但是也不是一概而论的,部分解释型语言的解释器通过运行时动态优化代码,甚至能够使解释型语言的性能不低于编译型语言。

1.3 Java与C#语言

JAVA语言

Java语言是一种混合型语言(编译加解释型),同时具备编译特性与解释特性(其实,确切地说JAVA就是解释型语言,其所谓的编译过程只是将.java文件编译成平台无关的字节码.class文件,并不是像C一样编译成可执行的机器语言)。作为编译型语言,JAVA程序要被统一编译成字节码文件(文件后缀是.class,这种文件在java中又称为类文件)。java字节码文件不能在计算机上直接执行,它需要被JAVA虚拟机翻译成本地的机器码后才能执行,而Java虚拟机的翻译过程则是解释型的。

Java的字节码文件首先被加载到计算机内存里,然后读出一条指令,翻译一条指令,执行一条指令,该过程被称为java语言的解释执行,是由java虚拟机(JVM, Java Virtual Machine)完成的。在现实中,java开发工具JDK提供了两个重要的命令来完成上面的编译和解释过程:javac.exe和java.exe。前者对源文件进行编译生成字节码文件,后者是加载字节码文件解释成机器码。(补充:常见的JVM例如Hotspot虚拟机,都提供了JIT(just in time)机制,此机制被称为动态编译机制,它可以将反复执行的热点代码直接编译成机器码,这种情况下部分热点代码的执行就属于编译执行,而不是解释执行了。)

C#语言

C#语言是编译型语言,但是其“编译”过程比较特殊。C#程序在第一次运行的时候,会依赖其.NET Framework平台,编译成IL中间码,然后由JIT compiler翻译成本地的机器码执行。从第二次在运行相同的程序,则不需要再执行以上编译和翻译过程,而是直接运行第一次翻译成的机器码。所以对于C#程序而言,通常第一次运行时间会很长,但从第二次开始,程序的执行时间会快很多。

为什么C#要进行两次“编译”?其实,微软想通过动态编译(由JIT compiler)来实现程序运行的最优化。如果代码在运行前进行动态编译运行,那么JIT compiler可以很智能的根据你本地机器的硬件条件来进行优化,比如使用更好的register,机器指令等等。

2. Java8 的新特性

2.1 Lambda表达

Lambda表达式,也称闭包,允许把函数作为一个方法的参数(函数作为参数传递进方法中)。

lambda表达式的重要特性:

  • 可选类型声明:不需要声明参数类型,编译器可以统一识别参数值;
  • 可选的参数圆括号: 一个参数无需定义圆括号,但是多个参数需要定义圆括号;
  • 可选的大括号: 如果主体包含了一个语句,就不需要大括号;
  • 可选的返回关键字:如果主体只有一个表达式返回值则编译器会自动返回值,大括号需要指明表达式返回了一个数值。

2.2 函数式接口(Functional Interface)

函数式接口就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口(默认方法与静态方法并不影响函数式接口的契约)。函数式接口可以被隐式转换为lambda表达式。

例如定义一个函数式接口:

1
2
3
4
5
@FunctionalInterface
interface GreetingService 
{
    void sayMessage(String message);
}

那么就可以用lambda表达式来表示该接口的一个实现(JAVA8 之前一般都是用匿名类来实现的):

1
GreetingService greetService1 = message -> System.out.println("Hello " + message);

补充:函数式接口里是可以包含Object里的public方法,这些方法对于函数式接口来说,不被当成抽象方法(虽然他们是抽象方法);因为任何一个函数式接口的实现,都默认继承了Object类,包含了java.lang.Object里对这些抽象方法的实现。

2.3 方法引用

方法引用提供了非常有用的语法,可以直接引用已有Java类或对象(实例)的方法或构造器。与lambda联合使用,方法引用可以使语言的构造更紧凑简洁,减少冗余代码。(如果lambda表达式的代码块中只有一条代码。可以使用方法引用和构造器引用)

其包含常见的方法引用如下所示:

  • 构造器引用:语法是Class::new,或者更一般的Class< T >::new;
  • 静态方法引用:语法是Class::static_method;
  • 特定类的任意对象的方法引用:语法是Class::method;
  • 特定对象的方法引用:语法是instance::method

给出一个截图如下:

方法引用

2.4 默认方法与静态方法

Java 8用默认方法与静态方法这两个新概念来扩展接口的声明。默认方法使接口有点像Traits(Scala中特征(trait)类似于Java中的Interface,但它可以包含实现代码,也就是目前Java8新增的功能),但与传统的接口又有些不一样,它允许在已有的接口中添加新方法,而同时又保持了与旧版本代码的兼容性。主要目的是为了升级标准JDK接口,另外也是为了能在JDK8中顺畅的使用Lamb的表达式。

在方法名前面加个 default 关键字即可实现默认方法。此外,java 8 带来的另外一个有趣的特性就是可以在接口中可以定义静态方法。

1
2
3
4
5
6
private interface DefaulableFactory {
    // Interfaces now allow static methods
    static Defaulable create( Supplier< Defaulable > supplier ) {
        return supplier.get();
    }
}

在JVM中,默认方法的实现是非常高效的,并且通过字节码指令为方法调用提供了支持。默认方法允许继续使用现有的Java接口,而同时能够保障正常的编译过程。这方面好的例子是大量的方法被添加到java.util.Collection接口中去:stream(),parallelStream(),forEach(),removeIf() ……

2.5 重复注解

自从Java 5引入了注解机制,这一特性就变得非常流行并且广为使用。然而,使用注解的一个限制是相同的注解在同一位置只能声明一次,不能声明多次。Java 8打破了这条规则,引入了重复注解机制,这样相同的注解可以在同一地方声明多次。

在Java 8中使用@Repeatable注解定义重复注解,实际上,这并不是语言层面的改进,而是编译器做的一个trick,底层的技术仍然相同。可以利用下面的代码说明:

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
package com.javacodegeeks.java8.repeatable.annotations;
 
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
 
public class RepeatingAnnotations {
    @Target( ElementType.TYPE )
    @Retention( RetentionPolicy.RUNTIME )
    public @interface Filters {
        Filter[] value();
    }
 
    @Target( ElementType.TYPE )
    @Retention( RetentionPolicy.RUNTIME )
    @Repeatable( Filters.class )
    public @interface Filter {
        String value();
    };
 
    @Filter( "filter1" )
    @Filter( "filter2" )
    public interface Filterable {        
    }
 
    public static void main(String[] args) {
        for( Filter filter: Filterable.class.getAnnotationsByType( Filter.class ) ) {
            System.out.println( filter.value() );
        }
    }
}

正如我们看到的,这里有个使用@Repeatable( Filters.class )注解的注解类Filter,Filters仅仅是Filter注解的数组,但Java编译器并不想让程序员意识到Filters的存在。这样,接口Filterable就拥有了两次Filter(并没有提到Filter)注解。

同时,反射相关的API提供了新的函数getAnnotationsByType()来返回重复注解的类型(请注意Filterable.class.getAnnotation( Filters.class )经编译器处理后将会返回Filters的实例)。

2.6 Optional类

Optional 类已经成为 Java 8 类库的一部分,用来解决空指针异常。Optional实际上是一个容器:它可以保存类型T的值,或者仅仅保存null.

给出下面的例子来演示使用Optional类:一个允许空值,一个不允许为空值:

1
2
3
4
5
6
7
8
9
10
Optional<String> optionalS = Optional.ofNullable(null);
    System.out.println("Full name is set?" + optionalS.isPresent());
    System.out.println("Full name:" + optionalS.orElseGet(() -> "[none]"));
    System.out.println(optionalS.map(s -> "Hey " + s + "!").orElse("Hey Stranger!"));
    System.out.println();

    Optional<String> optionalS1 = Optional.of("Tom");
    System.out.println("Full name is set?" + optionalS1.isPresent());
    System.out.println("Full name:" + optionalS1.orElseGet(() -> "[none]"));
    System.out.println(optionalS1.map(s -> "Hey " + s + "!").orElse("Hey Stranger!"));

如果Optional类的实例为非空值的话,isPresent()返回true,否从返回false。为了防止Optional为空值,orElseGet()方法通过回调函数来产生一个默认值。map()函数对当前Optional的值进行转化,然后返回一个新的Optional实例。orElse()方法和orElseGet()方法类似,但是orElse接受一个默认值而不是一个回调函数。下面是这个程序的输出:

1
2
3
4
5
6
7
Full name is set?false
Full name:[none]
Hey Stranger!

Full name is set?true
Full name:Tom
Hey Tom!

2.7 Stream API

java8新增了Stream API,把真正的函数式编程风格引入到java中。Stream使用一种类似用 SQL 语句从数据库查询数据的直观方式来提供一种对 Java 集合运算和表达的高阶抽象。这种风格将要处理的元素集合看作一种流, 流在管道中传输, 并且可以在管道的节点上进行处理, 比如筛选, 排序,聚合等。元素流在管道中经过中间操作(intermediate operation)的处理,最后由最终操作(terminal operation)得到前面处理的结果。

Stream是一个来自数据源的元素队列并支持聚合操作:

  • 元素是特定类型的对象,形成一个队列。Java中的Stream并不会存储对象,而是按需计算;
  • 数据源流的来源,可以是集合,数组,I/O channel, 产生器generator等;
  • 聚合操作类似于SQL语句一样的操作,比如filter,map,reduce,find,match,sorted等。

且和以前的Collection操作不同,Stream操作还有两个基础的特征:

  • Pipelining:中间操作都会返回流对象本身。这样多个操作可以串联成一个管道,如同流式风格。这样做可以对操作优化,比如延迟执行和短路。
  • 内部迭代:以前对集合遍历都是通过Iterator或者For-Each的方式, 显式的在集合外部进行迭代, 这叫做外部迭代。 Stream提供了内部迭代的方式, 通过访问者模式(Visitor)实现。

2.8 Date/Time API

java8 通过发布新的Date-Time API 来进一步加强对日前和时间的处理。

java8 在java.time包下提供了许多新的API,以下给出两个比较重要的API:

  • Local(本地)-简化了日期时间的处理,没有时区的问题
  • Zoned(时区)-通过制定的时区处理日期时间

新的java.time包涵盖了所有处理日期,时间,日期/时间,时区,时刻(instants),过程(during)与时钟(clock)的操作。

2.9 JavaScript引擎Nashorn

Java 8提供了一个新的Nashorn javascript引擎,它允许我们在JVM上运行特定的javascript应用。

Nashorn就是javax.script.ScriptEngine的另一种实现,并且它们俩遵循相同的规则,允许Java与JavaScript相互调用。下面看一个例子:

1
2
3
4
5
6
 
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName( "JavaScript" );
         
System.out.println( engine.getClass().getName() );
System.out.println( "Result:" + engine.eval( "function f() { return 1; }; f() + 1;" ) );

程序在控制台上的输出:

1
2
jdk.nashorn.api.scripting.NashornScriptEngine
Result: 2

2.10 Base64,并行数组,并发(Concurrency)

java8中,Base64编码已经成为Java类库的标准;java8 新增了大量的方法来对数组进行并行处理,可以说最重要的是parallelSort()方法,因为它可以在多核机器上极大提高数组排序的速度;在新增Stream机制与lambda的基础之上,在java.util.concurrent.ConcurrentHashMap中加入了一些新方法来支持聚集操作。同时也在java.util.concurrent.ForkJoinPool类中加入了一些新方法来支持共有资源池(common pool)。

2.11 新的Java工具

java8带来了一些新的命令行工具。其包括Nashorn引擎jjs,类依赖分析器jdeps。

jjs是个基于Nashorn引擎的命令行工具。它接受一些JavaScript源代码为参数,并且执行这些源代码。

jdeps是一个很有用的命令行工具。它可以显示Java类的包级别或类级别的依赖。它接受一个.class文件,一个目录,或者一个jar文件作为输入。jdeps默认把结果输出到系统输出(控制台)上。

3. java面试常问的关键字总结

3.1 final关键字

在java中,final关键字可以用来修饰类,成员方法和变量(包括成员变量和局部变量)。下面从这三个方面来总结一下final关键字的基本用法:

1.修饰类

当一个类被final修饰时,表明这个类不能被继承。final类中的成员变量可以根据需要设为final,但是注意final类中的所有成员方法都会被隐式地指定为final方法.并且,需要注意的是一个类不能既被声明为abstract,又被声明为final

2.修饰方法

如果在方法前面加final关键字,则表示该方法不能被子类重写。使用final修饰方法的原因有两个:一是将方法锁住,防止任何继承类去修改它的定义;第二个原因是提高效率,final修饰的方法比非final方法要快(早期编译器将所有对此方法的调用转化为inline(行内机制),即可以将此方法直接复制在调用处,而不是进行例行的方法调用(保存断点、压栈),可以提高效率。但如果过多的话,会造成代码膨胀,反而会影响效率,慎用。)。

因此,如果只有在想明确禁止 该方法在子类中被覆盖的情况下才将方法设置为final的。即父类的final方法是不能被子类所覆盖的,也就是说子类是不能够存在和父类一模一样的方法的。但是,子类可以重载多个final修饰的方法。此外,如果父类中final修饰的方法同时访问控制权限为private,将会导致子类中不能直接继承到此方法,因此,此时可以在子类中定义相同的方法名和参数,此时不再产生重写与final的矛盾,而是在子类中重新定义了新的方法。(注:类的private方法会被隐式地指定为final方法)

3.修饰变量

final成员变量表示常量,只能被赋值一次,赋值后值不再改变。当final修饰一个基本数据类型的变量时,其数据一旦在初始化后不能改变;如果final修饰一个引用类型时,则在对其初始化后便不能再让其指向其他对象了,但是该引用所指向的对象的内容是可以发生变化的。本质上是一回事,因为引用的值是一个地址,final要求值,即地址的值不发生变化。

final修饰成员变量时,必须要显示地进行初始化:

  • 类变量:必须在静态初始化块中指定初始值或者声明该类变量的时候指定初始值,而且只能在这两个地方的其中之一指定;
  • 实例变量: 必须在非静态初始化块、声明该实例变量或构造器中指定初始值,而且只能在这三个地方的其中之一指定。

final修饰局部变量时,局部变量必须显示初始化。但final修饰形参时,因为形参在调用该方法时,是由系统根据传入的参数来完成初始化,因此使用final修饰的形参不能被赋值。

1
2
3
4
5
public void test(final int a){

    //不能对final修饰的形参赋值,下面的语句非法
    //a = 5;
}

可执行“宏替换”的final变量

当对于一个final变量而言,不管它是类变量、实例变量,还是局部变量,只要该变量满足三个条件,这个final变量就不在是一个变量,而是相当于一个直接量(编译器常量,不需要在运行时确定)。

  • 使用final修饰且定义变量时制定了初始值;
  • 初始值可以在编译期间被确定下来;
  • 除了赋直接量的情况外,如果被赋的表达式只是基本的算术表达式或字符串连接运算,没有访问普通变量,调用方法,也符合条件。

给出例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Test { 
    public static void main(String[] args)  { 
        String a = "hello2";   
        final String b = "hello"; 
        final String b1 = getHello();
        String d = "hello"; 
        String c = b + 2;  //编译时,当做编译器常量,直接替换值,因此直接指向字符串常量池中的“hello2”
        String c1 = b1 + 2;  //编译期间无法获取b1值,运行时才能确定,所以编译器无优化
        String e = d + 2; 
        System.out.println((a == c)); //true
        System.out.println((a == e)); //false
        System.out.println((a == c1)); //false
    } 

    public static String getHello(){
        return "hello";
    }
} 

3.2 Synchronized关键字

3.2.1 基本概述

在开始讲synchronized关键字之前,先补充一下关于Thread的几个重要方法:

  • start()方法:调用该方法开始执行该线程;
  • stop()方法:调用该方法强制结束该线程执行;
  • join()方法: 调用该方法等待该线程结束;
  • sleep()方法: 调用该方法该线程进入等待;
  • run()方法: 调用该方法直接执行线程的run()方法,但是线程调用start()方法也会运行run()方法,区别就是一个是由线程调度运行run()方法,一个是直接调用了线程中的run()方法(和普通对象调用方法一样)。

此外,经常混淆wait()和notify()方法,其实它们是Object类的方法,不是Thread的方法。同时,wait()和notify()方法配合使用,分别表示线程挂起和线程恢复。(补充:wait()和sleep()的区别,简单来说wait()会释放对象锁而sleep()不会释放对象锁)

最后,补充一下锁所拥有的类型:

  • 可重入锁:在执行对象中所有同步方法不用再次获得锁
  • 可中断锁:在等待获取锁过程中可中断
  • 公平锁:按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利
  • 读写锁: 对资源读取和写入的时候拆分为2部分处理,读的时候可以多线程一起读,写的时候必须同步地写.

synchronized锁什么?锁对象。锁的对象包括:this, 临界资源对象, Class类对象。其同步的三种用法:

  1. 同步实例方法,锁的是当前实例对象;
  2. 同步类方法,锁的是当前类对象;
  3. 同步代码块,锁的是括号里面的对象。
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
public class SynchronizedTest {

    /**
     * 同步实例方法,锁实例对象
     */
    public synchronized void test() {
    }

    /**
     * 同步类方法,锁类对象
     */
    public synchronized static void test1() {
    }

    /**
     * 同步代码块
     */
    public void test2() {
        // 锁类对象
        synchronized (SynchronizedTest.class) {
            // 锁实例对象
            synchronized (this) {

            }
        }
    }
}

3.2.2 synchronized实现原理

synchronized可以保证方法或者代码块在运行时,同一个时刻只有一个方法可以进入到临界区(原子性),同时它还可以保证共享变量的内存可见性。因为 synchronized 无论是同步的方法还是同步的代码块,都会先把主内存的数据拷贝到工作内存中,同步代码块结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。更重要的是禁用了乱序重组以及保证了值对存储器的写入,这样就可以保证可见性。

利用javap -verbose来查看上述示例的class文件信息:

synchronized案例分析

分析一下上图结果:

  • 同步方法:方法级同步没有通过字节码指令来控制,它实现在方法调用和返回操作之中。当方法调用时,调用指定会检查方法ACC_SYNCHRONIZED访问标志是否被设置,若设置了则执行线程需要持有管程(Monitor,或称为监视器锁)才能运行方法,当方法完成(无论是否出现异常)时释放管程。
  • 同步代码块: synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令,每条monitorenter指令都必须执行其对应的monitorexit指令,为了保证方法异常完成时这两条指令依然能够正确执行,编译器会自动生成一个异常处理器,其目的就是为了执行monitorexit指令(图中14-18、24-30为异常流程)。

下面我们继续来分析,但在深入了解之前我们需要了解两个重要的概念:Java对象头,Monitor.

Java对象头

首先,我们来看一下对象内存的简图: 对象内存简图

  • 对象头:其包含两部分Mark Word和Klass Pointer(类型指针)。Mark word用于存储对象自身的运行时数据,如Hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等;Klass Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象时哪个类的实例。(Mark World 是实现轻量级锁和偏向锁的关键,下面会重点阐述)。
  • 实例变量:存放类的属性数据信息,包括父类的属性信息。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

Mark World用于存储对象自身的运行时数据,如如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

Java对象头的存储结构

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):

变化状态

Monitor可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切解释对象一样,所有的Java对象是天生的Mointor(换句话说,每个Java对象都有成为Monitor的潜质),因为在Java的设计中,每一个对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。每个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态,如图:

Monitor状态图

Monitor是线程私有的数据结构,每一个线程都有一个可用的monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段用来存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下所示:

Monitor结构

  • Owner: 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
  • EntryQ: 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record的线程;
  • RcThis: 表示blocked或waiting在该monitor record上的所有线程的个数;
  • Nest: 用来实现重入锁的计数;
  • HashCode: 保存从对象头拷贝过来的HashCode值(可能还包含GC age);
  • Candidate: 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后又因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

我们知道synchronized是重量级锁,效率不怎么滴,同时这个观念也一直存在我们脑海里,不过在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了,那么JVM采用了那些优化手段呢?

锁优化

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。优化主要解决三种场景:

  1. 只有一个线程进入临界区,偏向锁
  2. 多线程未竞争,轻量级锁
  3. 多线程竞争,重量级锁

偏向锁->轻量级锁->重量级锁过程(注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率)。

1.自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的事情,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

自旋锁,就是让线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待?就是执行一段无意义的循环即可(自旋)。

自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整;

如果通过参数-XX:preBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。

2.自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

3.锁消除

为了保证数据的完整性,我们在进行操作的时候需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

如果不存在竞争,为什么还需要加锁?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:

1
2
3
4
5
6
7
8
public void vectorTest(){
        Vector<String> vector = new Vector<String>();
        for(int i = 0 ; i < 10 ; i++){
            vector.add(i + "");
        }

        System.out.println(vector);
    }

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

4.锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小——仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗化就是指将多个连续的加锁、解锁操作连接在一起,扩展成为一个范围更大的锁。。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

5.轻量级锁

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下所示:

获取锁

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤3;
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志变成10,后面等待的线程将会进入阻塞状态。

释放锁(轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下):

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

下图给出轻量级锁的获取和释放过程:

轻量级锁的获取和释放过程

6.偏向锁

引入偏向锁的目的是在没有多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。(其引入背景是大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。)上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:

获取锁

  1. 检查Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤5,否则执行步骤3;
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行步骤4;
  4. 通过CAS操作竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程将被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块。

释放锁

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态。

下图是偏向锁的获取和释放流程:

偏向锁的获取和释放流程

7.重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

8.不同锁的比较

不同锁的比较

参考博文

JVM源码分析之java对象头实现

Java并发——关键字synchronized解析

深入分析synchronized的实现原理

Java中synchronized的实现原理与应用

JVM内部细节之一:synchronized关键字及实现细节(轻量级锁Lightweight Locking)

JVM源码分析之synchronized实现

Java 并发编程之 Synchronized 关键字最全讲解

3.2.3 简短的总结

关于synchronized的底层实现,不去考虑具体锁优化的细节,其具体过程如下:

同步代码块基于进入和退出管程(Monitor)对象实现。每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

被synchronized修饰的同步方法并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

3.3 volatile关键字

首先对于volatile关键字而言,简要的描述是主要有以下两个特性:

  • 可见性。保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这个值对于其他线程来说是立即可见的。
  • 有序性。禁止进行指令重排序,阻止编译器对代码的优化。

下面,来介绍几个知识点:

3.3.1 java内存模型

我们知道,由于CPU执行指令很快,但是内存访问速度很慢,其运算速度之间有着几个数量级的差异,为此,现代计算机系统都不得不加入了多层高速缓存来作为处理器和内存之间的缓冲。Java虚拟机规范试图指定一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。

JMM(Java Memory Model)的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出共享变量的底层细节。注意,这里的变量是指实例字段、静态字段和构成数组对象的元素,不包括局部变量和方法参数,因为后者都线程私有的,不会被共享,就不存在竞争问题

Java内存模型有主内存和工作内存,工作内存保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递都需要通过主内存来实现。(注意:当一个变量被volatile修饰后,JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存。表示着线程工作内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。)下图给出具体的内存模型图:

java内存模型

使用工作内存和主内存,虽然加快了速度,但是也带来了一些问题,例如:

1
i = i + 1;

假设i的初始值为0,当只有一个线程执行它时,结果肯定是1,但是如果是当两个线程执行时,得到的结果就不一定是2了,可能会存在下面的情况:

1
2
3
4
5
6
线程1 load i from 主存    // i = 0
        i + 1  // i = 1
线程2 load i from主存  // 因为线程1还没将i的值写回主存,所以i还是0
        i +  1 //i = 1
线程1:  save i to 主存
线程2 save i to 主存

如果两个线程遵循上面的执行过程,那么 i 的最终值竟然是 1 。如果最后的写回生效的慢,你再读取 i 的值,都可能会是 0 ,这就是缓存不一致的问题。JMM主要围绕在并发过程中如何处理原子性、可见性、有序性这三个特征来建立的。通过解决这三个问题,就可以解决缓存不一致的问题。而volatile关键字跟可见性和有序性都相关

3.3.2 原子性、可见性、有序性(额外补充)

原子性:由于Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write(操作详细说明见深入理解Java虚拟机书籍),我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和Unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是可却提供了更高层次的字节码指令monitorenter和mointorexit来隐式地使用者两个操作,这两个字节码反映到java代码就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即获知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这正依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。只不过volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点。

除了volatile关键字之外,java还有两个关键字可以实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存”这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把”this”的引用传递出去,那么其他线程就能看见final字段的值。

有序性:JMM允许编译器和处理器重新排序指令,但是指定了as-if-串行语义,也就是说,无论如何重新排序,程序的执行结果都不能改变。java程序天然的有序性可以用一句话来总结:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句就是“线程内表现为串行的语义”,后半句是指“指令重排序”和“工作内存与主内存延迟同步”现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻值允许一个线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

3.3.3 何为指令重排序?

在执行程序的时候为了提升性能,编译器和处理器通常会对指令进行重排序:

  1. 编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  2. 处理器重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

因为指令重排序会影响到多线程执行的正确性,那我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?在回答这个问题之前。我们先来了解一个原则:happens-before,先行发生原则

happens-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推出来,那么它们就不能保证有序性,可以随意进行重排序(换句话说就是,符合这些先行发生关系不需要任何的同步操作,就可以保证其线程安全)。其定义如下:

  1. 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则(Mointor Lock Rule):一个unlock操作先行发生于后面对于同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”指的是时间上的先后顺序。
  3. volatile变量规则:对于一个volatile变量的写操作先行发生于后面对这个变量的读操作。
  4. 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止运行。
  6. 线程中断规则:对线程的interrupt()方法的调用优先于发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法执行。
  8. 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

我们主要看第3点:对于一个volatile变量的写操作先行发生于后面对这个变量的读操作。为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。(注意:观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。)

3.3.4 内存屏障及volatile内存语义实现

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如下表:

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2及所有后续装载指令的装载
StoreStore Barriers Store1;StoreStore;Store2 确认Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据的装载先于Store2及所有后续存储指令,刷新到内存
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器可见(刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行屏障之后的内存访问指令。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的写与volatile写重排序)。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的读和写重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止volatile读与后面的读操作重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止volatile读与后面的写操作重排序)。

其中重点说下StoreLaod屏障,它是确保可见性的关键,因为它会将屏障之前的写缓冲区中的数据全部刷新到主内存中。上述内存屏障插入策略非常保守,但它可以保证在任意处理平台,任意的程序中都能得到正确的volatile语义。下面是保守策略(为什么说保守呢,因为有些在实际的场景是可省略的)下,volatile 写操作 插入内存屏障后生成的指令序列示意图:

volatile写操作

其中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作对任意处理器可见(把它刷新到主内存)。另外volatile写后面有StoreLoad屏障,此屏障的作用是避免volatile写与后面可能有的读或写操作进行重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)为了保证能正确实现volatile的内存语义,JMM采取了保守策略:在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见模式是:一个写线程写volatile变量,多个读线程去读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里也可看出JMM在实现上的一个特点:首先确保正确性,然后再去追求效率(其实我们工作中编码也是一样)。

下面是在保守策略下,volatile读插入内存屏障后生产的指令序列示意图:

volatile读

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况忽略不必要的屏障。在JMM基础中就有提到过各个处理器对各个屏障的支持度,其中x86处理器仅会对写-读操作做重排序。

3.3.5 从汇编层看volatile关键字

前面说过,volatile关键字修饰的变量,编程汇编代码后,会在变量的前面插入一条LOCK指令。LOCK指令属于系统层级: LOCK前缀会使处理器执行当前指令时产生一个LOCK#信号,显示的锁定总线。

来看一下Lock指令的作用:

  • 锁总线:其他cpu对内存的读写请求会被阻塞,直到锁释放,不过因为锁总线的的开销太大,后来采用锁缓存来代替锁总线;
  • lock后的写操作会回写已修改的数据,同时让其它cpu相关缓存行失效,从而重新从主存中加载最新的数据。
  • 不是内存屏障却完成类似内存屏障的功能,阻止屏障两边的执行重排序。

3.3.6 volatile修饰的变量原子性问题

对于被 volatile 修饰的变量,对任意(包括64位long类型和double类型)单个volatile变量的读/写具有原子性,记着是对单个volatile变量的读或写才具有原子性,另外任何复合操作都不能保证原子性,如a++,a = a+1, a = b。特别注意a = b这类,它实际上包含2个操作,它先要去读取b的值,再将b的值写入工作内存,虽然读取b的值以及将b的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。

想要理解透volatile特性有一个很好的方法,就是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。

3.3.7 volatile关键字的应用场景

问题入手:被部分初始化的对象。给出一个懒加载的单例模式如下:

1
2
3
4
5
6
7
8
9
10
class Singleton {
    private static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance() {
        if ( instance == null ) { //这里存在竞态条件
            instance = new Singleton();
        }
        return instance;
    }
}

竞态条件会导致instance引用被多次赋值,使用户得到两个不同的单例。

为了解决这个问题,可以使用synchronized关键字将getInstance方法改为同步方法,但是这样串行化的单例是不能忍受的。为此。设计了DCL机制,使得大部分请求都不会进入阻塞代码块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Singleton {
    private static Singleton instance;
    private Singleton(){}
    public static Singleton getInstance() {
        if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象”
            synchronized (Singleton.class) {
                if ( instance == null ) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

“看起来”非常完美:既减少了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个”被部分初始化的对象”。问题出在这行简单的赋值语句:

1
instance = new Singleton();

它并不是一个原子操作。事实上,它可以抽象为下面几条jvm指令:

1
2
3
memory = allocate();    //1:分配对象的内存空间
initInstance(memory);   //2:初始化对象
instance = memory;      //3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,经过重排序后如下:

1
2
3
memory = allocate();    //1:分配对象的内存空间
instance = memory;      //3:设置instance指向刚分配的内存地址(此时对象还未初始化)
initInstance(memory);   //2:初始化对象

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——即,引用instance指向了一个”被部分初始化的对象”。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。

解决这个该问题,只需要将instance声明为volatile变量:

1
private static volatile Singleton instance;

也就是说,在只有DCL没有volatile的懒加载单例模式中,仍然存在着并发陷阱。我确实不会拿到两个不同的单例了,但我会拿到“半个”单例(未完成初始化)。

3.3.8 总结

其实volatile保持内存可见性和防止指令重排序的原理,本质上是同一个问题,也都依靠内存屏障得到解决。volatile是一种比锁更轻量级的线程之间通信的机制,volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以保证对整个临界区代码执行具有原子性,在功能上,锁比voatile更强大,在可伸缩性和执行性能上,volatile更有优势,但是volatile并不能代替锁。其常见应用场景是状态标记变量和double check(DCL,双重检查锁)。

参考文章:

java面试之volatile关键字

java并发编程系列-volatile内存实现和原理

Java面试官最常问的volatile关键字

【阿里面试系列】并发编程之Volatile的作用及原理

Java线程面试题(03) Java中的volatile如何工作? Java中的volatile关键字示例

面试题:volatile关键字的作用、原理

jdk源码剖析二: 对象内存布局、synchronized终极原理

java 轻量级同步volatile关键字简介与可见性有序性与synchronized区别 多线程中篇(十二)

3.4 static 关键字

static方法就是没有this的方法。在static方法内部不能调用非静态方法,反过来是可以的。而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用static方法,这实际上正是static方法的主要用途。

  • static关键字可以用来修饰:属性、方法、内部类、代码块
  • static修饰的资源属于类级别,是全体对象实例共享的资源;
  • 使用static修饰的属性,静态属性是在类的加载期间初始化的,使用类名.属性访问。

3.4.1 静态变量

在声明的时候加上static关键字,该变量即是静态变量。静态变量和非静态变量的区别是:静态变量时是被所有对象共享的,在内存中只有一个副本,它当且仅当在类初次加载时会被初始化。而非静态变量是对象所拥有的,在创建对象的时候被初始化,存在多个副本,各个对象拥有的副本互不影响。static修饰的变量在类加载期间初始化,且在方法区中分配,属于线程共享区,所有的对象实例共享一份数据。

3.4.2 静态方法

static方法一般称为静态方法,由于静态方法不依赖于任何对象就可以进行访问,因此对于静态方法来说,是没有this的,因为它不依附于任何对象,既然都没有对象,更没有this了。并且由于这个特性,在静态方法中不能访问类的非静态成员变量和非静态成员方法,因为非静态成员方法/变量都是必须依赖具体的对象才能够被调用。

static修饰成员方法最大的作用,就是可以使用”类名.方法名”的方式操作方法,避免了先要new出对象的繁琐和资源消耗,我们可能会经常在帮助类中看到它的使用。我们最常见的static方法就是main方法,至于为什么main方法必须是static的,现在就很清楚了。因为程序在执行main方法的时候没有创建任何对象,因此只有通过类名来访问。

3.4.3 静态代码块

static关键字还有一个比较关键的作用就是形成静态代码块以优化程序性能。static块可以置于类中的任何地方,类中可以有多个static块。在类初次被加载的时候,会按照static块的顺序来执行每个static块,并且只会执行一次。一般用来在类加载以后初始化一些静态资源时使用例如加载配置文件。

3.4.4 静态内部类

被static修饰的类,并且处于某个类的内部。它可以访问外部类的静态成员,包括private成员。但是它不能访问外部类的非静态成员。

3.4.5 常见面试题

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 Test {
    Person person = new Person("Test");
    static{
        System.out.println("test static");
    }
     
    public Test() {
        System.out.println("test constructor");
    }
     
    public static void main(String[] args) {
        new MyClass();
    }
}
 
class Person{
    static{
        System.out.println("person static");
    }
    public Person(String str) {
        System.out.println("person "+str);
    }
}
 
 
class MyClass extends Test {
    Person person = new Person("MyClass");
    static{
        System.out.println("myclass static");
    }
     
    public MyClass() {
        System.out.println("myclass constructor");
    }
}

上述代码的输出结果是什么:

1
2
3
4
5
6
7
test static
myclass static
person static
person Test
test constructor
person MyClass
myclass constructor

我们来想一下这段代码的具体执行过程。首先加载Test类,因此会执行Test类中的static块。接着执行new MyClass(),而MyClass类还没有被加载,因此需要加载MyClass类。在加载MyClass类的时候,发现MyClass类继承自Test类,但是由于Test类已经被加载了,所以只需要加载MyClass类,那么就会执行MyClass类的中的static块。在加载完之后,就通过构造器来生成对象。而在生成对象的时候,必须先初始化父类的成员变量,因此会执行Test中的Person person = new Person(),而Person类还没有被加载过,因此会先加载Person类并执行Person类中的static块,接着执行父类的构造器,完成了父类的初始化,然后就来初始化自身了,因此会接着执行MyClass中的Person person = new Person(),最后执行MyClass的构造器。

3.5 finally关键字

finally关键字经常与try-catch搭配使用,在程序进入try块之后,无论程序是因为异常而中止或其他方式返回中止的,finally块的代码都会被执行。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
public class FinallyTest {
    public static void main(String[] args) {
        System.out.println("method() : " + method());
        System.out.println();
        System.out.println("method1() : " + method1());
        System.out.println();
        System.out.println("method2() : " + method2());
        System.out.println();
        System.out.println("method3() : " + method3().num);
        System.out.println();
        System.out.println("method4() : " + method4().num);
        System.out.println();
        System.out.println("method5() : " + method5().num);
    }
    private static int method() {
        int i = 1;
        try {
            i = 3;
            System.out.println("method()-try : i = " + i);
            return i;
        } finally {
            i = 7;
            System.out.println("method()-finally : i = " + i);
        }
    }

    private static int method1() {
        int i = 1;
        try {
            i = 3;
            System.out.println("method1()-try : i = " + i);
            throw new Exception();
            //return i;
        } catch (Exception e) {
            i = 5;
            System.out.println("method1()-catch : i = " + i);
            return i;
        } finally {
            i = 7;
            System.out.println("method1()-finally : i = " + i);
        }
    }

    @SuppressWarnings("finally")
    private static int method2() {
        int i = 1;
        try {
            i = 3;
            System.out.println("method2()-try : i = " + i);
            return i;
        } finally {
            i = 7;
            System.out.println("method2()-finally : i = " + i);
            return i;
        }
    }

    private static A method3() {
        A a = new A();
        a.num = 1;
        try {
            a.num = 3;
            System.out.println("method3()-try : a.num = " + a.num);
            return a;
        } finally {
            a.num = 7;
            System.out.println("method3()-finally : a.num = " + a.num);
        }
    }

    private static A method4() {
        A a = new A();
        a.num = 1;
        try {
            a.num = 3;
            System.out.println("method4()-try : a.num = " + a.num);
            throw new Exception();
            //return a.num;
        } catch (Exception e) {
            a.num = 5;
            System.out.println("method4()-catch : a.num = " + a.num);
            return a;
        } finally {
            a.num = 7;
            System.out.println("method4()-finally : a.num = " + a.num);
        }
    }

    @SuppressWarnings("finally")
    private static A method5() {
        A a = new A();
        a.num = 1;
        try {
            a.num = 3;
            System.out.println("method5()-try : a.num = " + a.num);
            return a;
        } finally {
            a.num = 7;
            System.out.println("method5()-finally : a.num = " + a.num);
            return a;
        }
    }
}

class A {
    int num;
}

输出结果如下所示:

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
method()-try : i = 3
method()-finally : i = 7
method() : 3

method1()-try : i = 3
method1()-catch : i = 5
method1()-finally : i = 7
method1() : 5

method2()-try : i = 3
method2()-finally : i = 7
method2() : 7

method3()-try : a.num = 3
method3()-finally : a.num = 7
method3() : 7

method4()-try : a.num = 3
method4()-catch : a.num = 5
method4()-finally : a.num = 7
method4() : 7

method5()-try : a.num = 3
method5()-finally : a.num = 7
method5() : 7

从上面的执行结果可以得出以下几点:

  • 以上6个方法均表明:在try-finally或者try-catch-finally结构中,执行块中return之前都会执行finally代码块。除非try块或者catch块中存在System.exit(0)语句;
  • method2()和method5()表明:如果finally代码块中含有return语句,则直接执行return返回,不会返回try或者catch块中return语句;
  • method()和method1()方法表明:当return语句在try或者catch代码块中,finally代码块中对变量i的改变并不会影响返回i的值;method3()方法表明当finally代码块含有return语句时,方法直接返回finally域中变量i的值。(解释:对于finally块中没有return语句的情况,方法在返回之前会先将返回值保存在局部变量表中的某个slot中,然后执行finally块中的语句,之后再将保存在局部变量表中的某个slot中的数据放入操作数栈的栈顶并返回,因此对于基本数据类型而言,若在finally块中改变其值,并不会影响最后return的值。对于finally块中包含了return语句的情况,则在try块中的return执行之前,会先goto到finally块中,而在goto之前并不会对try块中要返回的值进行保存,而是直接去执行finally块中的语句,并最终执行finally块中的return语句,而忽略try块中的return语句,因此最终返回的值是在finally块中改变之后的值。)
  • method3(),method4()和method5()方法表明:返回的不是基本类型而是引用时,finally代码块中对引用属性的修改会直接体现在返回的对象中。(解释:保护的只是引用本身,对引用指向的对象的内容并没有改变)

总结:任何执行try块或者catch块的return语句之前,都会先执行finally块的代码,除非finally块中含有return语句;try块和catch块会对基本类型变量的值进行保护,finally块中对基本类型变量的改变不起作用,但是无法保护对象的属性。

4. 面向对象

4.1 hashCode()和equals()方法的联系

1.equals()方法

Object类的方法,默认检测一个对象是否等于另外一个对象,即判断两个对象是否具有相同的引用。它一般有两种使用情况:

  • 情况1: 类没有覆盖equals()方法。则通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象。
  • 情况2: 类覆盖equals()方法。一般,我们都覆盖equals()方法来比较两个内容是否相等;若它们的内容相等,则返回true。

equals()方法的等价关系:

  1. 自反性
    1
    
    x.equals(x); //true
    
  2. 对称性
    1
    
    x.equals(y) == y.equals(x); //true
    
  3. 传递性
    1
    2
    3
    
    if(x.equals(y) && y.equals(z)){
     x.equals(z); //true
    }
    
  4. 一致性:多次调用equals()方法结果不变
    1
    
    x.equals(y) == x.equals(y); //true
    
  5. 与null的比较:对任何不是null的对象x调用x.equals(null)结果都为false
    1
    
    x.equals(null); // false;
    

正确重写自定义对象的equals方法的步骤

  • 显示参数类型应该为Oject类型,以便覆盖Object类的equals方法;
  • 检测this与显示参数object是否为同一个引用;
  • 检测object 是否为null,如果是则返回false;
  • 比较this与Object是否属于同一个类。如果equals的语义在每个子类中有所改变,就使用getClass检测;如果所有的子类都拥有统一的语义,就使用instanceOf检测;
  • 将object转换为相应的类类型变量;
  • 对需要比较的属性进行比较,基本类型数据用==,对象类型用equals,相等则返回true,否则返回false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Employee {
    String name;
    String salary;    
    @Override
    public boolean equals(Object object) {        
        if (this == object) return true;  // 与自身比较返回true,同时处理null与null的比较
        if (object == null) return false;        
        if (!(object instanceof Employee))         
            return false;
        //如果equals的语义在每个子类中有所改变
        // if (getClass() != object.getClass()) 
        //     return false;

        Employee employee = (Employee)object;
        if (this.name == employee.name) return true; 
        return false;
    }
}

2.hashCode()方法

hashCode()方法也是Object类的一个默认方法,它默认作用是返回对象的存储地址,方法返回一个整形值的散列值。有两个关于hashCode()和equals()方法的重要规范:

  1. 若重写equals(Object object)方法,有必要重写hashCode()方法,确保通过equals()方法判断结果为true的两个对象具有相等的hashCode()返回值;
  2. 如果equals()返回false,即两个对象不相同,并不要求这两个对象调用hashCode()方法得到两个不相同的数。

根据这两个规定,可以有以下推论:

  1. 如果两个对象equals,Java运行时环境认为他们的hashCode()一定相等;
  2. 如果两个对象不equals,他们的hashCode()有可能相等;
  3. 如果两个对象hashcode相等,他们不一定equals;
  4. 如果两个对象hashcode不相等,他们一定不equals。

那么为什么要有hashCode()呢?其实这个问题的回答跟Java中的集合相关。Java中的集合(Collection)有两类:一类是List,再有一类是Set.这两者的区别就是前者集合内的元素是有序的,元素可以重复;后者元素无序,但元素不可重复。要想保证元素不重复,通用的方法就是Object.equals()方法了。但是,果每增加一个元素就检查一次,那么当元素很多时,后添加到集合中的元素比较的次数就非常多了。也就是说,如果集合中现在已经有1000个元素,那么第1001个元素加入集合时,它就要调用1000次equals方法。这显然会大大降低效率。于是,Java采用了哈希表的原理。

当集合要添加新的元素时,先调用这个元素的hashCode方法,就一下子能定位到它应该放置的物理位置上。如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了;如果这个位置上已经有元素了,就调用它的equals方法与新元素进行比较,相同的话就不存了,不相同就散列其它的地址。所以这里存在一个冲突解决的问题。这样一来实际调用equals方法的次数就大大降低了,几乎只需要一两次。

4.2 面向对象的的四个特征

4.2.1 封装

封装给对象提供了隐藏内部特性的行为和属性的能力。对一个类或对象实现良好的封装,可以实现以下目的:

  • 隐藏类的实现细节;
  • 让使用者只能通过事先预定的方法来访问数据,从而可以在该方法里加入控制逻辑,限制对成员变量的不合理访问;
  • 可进行数据检查,从而有利用保护对象信息的完整性;
  • 便于修改,提高代码的可维护性。

封装其实有两个方面的含义:把该隐藏的隐藏起来,把该暴露的暴露出来。这两个方面都需要通过Java提供的访问控制符来实现,为此,下面介绍一下访问控制符:

  1. private(当前类访问权限):如果类里的一个成员(包括成员变量、方法和构造器等)使用private访问控制符来修饰,则这个成员只能在当前类的内部被访问。
  2. default(包访问权限):如果类里的一个成员或者一个外部类不使用任何修饰符修饰,就称它是包访问权限的,default访问控制的成员或外部类可以被相同包下的其他类访问。
  3. protected(子类访问权限):如果一个成员使用protected访问控制符修饰,那么这个成员既可以被同一个包中的其他类访问,也可以被不同包中的子类访问。通常情况下使用protected来修饰一个方法,是希望其子类来重写这个方法。
  4. public(公共访问权限):如果一个成员使用public访问控制符修饰,或者一个外部类使用public访问控制符修饰,那么这个成员或外部类就可以被所有类访问,不管访问类和被访问类是否处于同一个包中,是否具有父子继承关系。

4.2.2 继承

继承给对象提供了从基类获取字段和方法的能力。继承提供了代码的可重用性,也可以在不修改类的情况下给现存的类添加新特性。

拓展:由于实际需要,某个类具有两个或两个以上的维度变化(例如我们去吃面:有拉面和板面两种选择,在这两中选择之上还有牛肉面和鸡蛋面两种选择,在这两层之上还有清淡、微辣、超辣等选择),如果仅仅使用继承实现这种需求,设计将会变得非常臃肿,这里我们可以引入桥接模式。桥接模式的做法就是把程序变化的部分抽象出来,让变化的部分与主类分离开来,从而将多个维度的变化彻底分离。最后提供一个管理类来组合不同维度上的变化,通过这个组合来满足业务需要。桥接模式在JavaEE应用中有非常广泛的应用。由于JavaEE应用需要实现跨数据库的功能,程序为了在不同的数据库之间迁移,系统需要在持久化技术这个维度上存在改变;另外,系统也需要在不同的业务逻辑之间迁移,因此也需要在业务逻辑这个维度迁移。因此,JavaEE应用都会推荐使用业务逻辑组件与DAO组件分离,让DAO组件负责持久化技术这个维度上的改变,让业务逻辑组件负责业务逻辑实现这个维度上的改变。JavaEE应用的DAO模式就是桥接模式的应用。从DAO组件的设计初衷来开,DAO组件是为了让应用在不同的持久化技术之间自由切换,也就是为了分离系统在持久化技术维度上的变化,从这个角度来看,JavaEE应用分离出DAO组件就是遵循桥接模式的。

4.2.3 多态

Java引用变量有两个类型:一个是编译时类型,一个是运行时类型。编译时类型由声明该变量是使用的类型决定,运行时类型由实际赋给该变量的对象决定。如果编译时类型和运行时类型不一致,就会出现所谓的多态。

多态性是指允许不同子类型的对象对同一消息作出不同的响应。简单的说就是用同样的对象引用调用同样的方法但是做了不同的事情。多态性分为编译时的多态性和运行时的多态性。如果将对象的方法视为对象向外界提供的服务,那么运行时的多态性可以解释为:当A系统访问B系统提供的服务时,B系统有多种提供服务的方式,但一切对A系统来说都是透明的(就像电动剃须刀是A系统,它的供电系统是B系统,B系统可以使用电池供电或者用交流电,甚至还有可能是太阳能,A系统只会通过B类对象调用供电的方法,但并不知道供电系统的底层实现是什么,究竟通过何种方式获得了动力)。方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重写(override)实现的是运行时的多态性(也称为后绑定)。

运行时的多态是面向对象最精髓的东西,要实现多态需要做两件事:

1). 方法重写(子类继承父类并重写父类中已有的或抽象的方法);

2). 对象造型(用父类型引用引用子类型对象,这样同样的引用调用同样的方法就会根据子类对象的不同而表现出不同的行为)。

4.2.4 抽象

抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行为抽象两方面。抽象只关注对象有哪些属性和行为,并不关注这些行为的细节是什么。

4.3 面向对象的六原则一法则

  1. 单一职责原则:一个类只做它该做的事情。(单一职责原则想表达的就是”高内聚”,写代码最终极的原则只有六个字”高内聚、低耦合”,所谓的高内聚就是一个代码模块只完成一项功能,在面向对象中,如果只让一个类完成它该做的事,而不涉及与它无关的领域就是践行了高内聚的原则,这个类就只有单一职责。)
  2. 开闭原则:软件实体应当对扩展开放,对修改关闭。(在理想的状态下,当我们需要为一个软件系统增加新功能时,只需要从原来的系统派生出一些新类就可以,不需要修改原来的任何一行代码。要做到开闭有两个要点:①抽象是关键,一个系统中如果没有抽象类或接口系统就没有扩展点;②封装可变性,将系统中的各种可变因素封装到一个继承结构中,如果多个可变因素混杂在一起,系统将变得复杂而混乱。)
  3. 依赖倒转原则:面向接口编程。(该原则说得直白和具体一些就是声明方法的参数类型、方法的返回类型、变量的引用类型时,尽可能使用抽象类型而不用具体类型,因为抽象类型可以被它的任何一个子类型所替代。)
  4. 里氏替换原则:任何时候都可以用子类型替换掉父类型。简单的说就是能用父类型的地方就一定能使用子类型。里氏替换原则可以检查继承关系是否合理,如果一个继承关系违背了里氏替换原则,那么这个继承关系一定是错误的,需要对代码进行重构。
  5. 接口隔离原则:接口要小而专,绝不能大而全。
  6. 合成聚合复用原则:优先使用聚合或合成关系复用代码。要说明的是,即使在Java的API中也有不少滥用继承的例子,例如Properties类继承了Hashtable类,Stack类继承了Vector类,这些继承明显就是错误的,更好的做法是在Properties类中放置一个Hashtable类型的成员并且将其键和值都设置为字符串来存储数据,而Stack类的设计也应该是在Stack类中放一个Vector对象来存储数据。记住:任何时候都不要继承工具类,工具是可以拥有并可以使用的,而不是拿来继承的。)

迪米特法则:迪米特法则又叫最少知识原则,一个对象应当对其他对象有尽可能少的了解。(迪米特法则简单的说就是如何做到”低耦合”,门面模式和调停者模式就是对迪米特法则的践行。Java Web开发中作为前端控制器的Servlet或Filter不就是一个门面吗,浏览器对服务器的运作方式一无所知,但是通过前端控制器就能够根据你的请求得到相应的服务。调停者模式也可以举一个简单的例子来说明,例如一台计算机,CPU、内存、硬盘、显卡、声卡各种设备需要相互配合才能很好的工作。迪米特法则用通俗的话来将就是不要和陌生人打交道,如果真的需要,找一个自己的朋友,让他替你和陌生人打交道。)

4.4 内部类

在某些情况下,会把一个类放在另一个类的内部定义,这个定义在其他类内部的类就被称为内部类(也可叫嵌套类),包含内部类的类也被称为外部类(也可叫宿主类)。(其中内部类最吸引人的原因是每个内部类都能独立地继承一个接口实现)。内部类主要有以下作用:

  • 内部类提供了更好的封装,可以把内部类隐藏在外部类之内,不允许同一个包中的其他类访问该类。假设需要创建Cow类,Cow类需要组合一个CowLeg对象,CowLeg类只有在Cow类里才有效,离开了Cow类之后没有任何意义。这种情况下,就可把CowLeg定义成Cow的内部类,不允许其他类访问CowLeg。
  • 内部类成员可以直接访问外部类的私有数据,因为内部类被当成其外部类成员,同一个类的成员之间可以互相访问。但是外部类不能访问内部类的实现细节,例如内部类的成员变量。
  • 匿名内部类适合用于创建那些仅仅需要使用一次的类。
  • 解决多重继承的问题。

使用内部类能够给我们带来以下特性:

  • 内部类可以用多个实例,每个实例都有自己的状态信息,并且与其他外围对象的信息相互独立。
  • 在单个外围类中,可以让多个内部类以不同的方式实现同一个接口,或者继承同一个类;
  • 创建内部类对象的时刻并不依赖于外围类对象的创建;
  • 内部类并没有令人迷惑的”is-a”关系,就是一个独立的实体;
  • 内部类提供了更好的封装,除了外围类,其他类都不能访问。

内部类与外部类的区别:

  • 内部类比外部类可以多使用三个修饰符:private, protected, static——外部类不可以使用这三个修饰符。
  • 非静态内部类不能拥有静态成员。

4.4.1 静态内部类

是用static修饰的内部类,作为外部类的静态成员,属于外部类本身。

  1. 静态内部类不能直接访问外部类的非静态成员,但是可以通过 new 外部类().成员 的方式访问;
  2. 如果外部类的静态成员与内部类的成员名称相同,可通过“类名.静态成员”访问外部类的静态成员;如果外部类的静态成员与内部类的成员名称不相同,则可通过“成员名”直接调用外部类的静态成员;
  3. 创建静态内部类的对象时,不需要外部类的对象,可以直接创建 “内部类 对象名 = new 内部类()”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Outer{
        private int age = 99;
        static String name = "Coco";
        public static class Inner{
            String name = "Jayden";
            public void show(){
                    System.out.println(Outer.name);
                    System.out.println(name);                   
            }
        }
        public static void main(String[] args){
            Inner i = new Inner();
            i.show();
        }
    }

4.4.2 成员内部类

作为类的成员,存在于某个类的内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Outer{
    private int age = 99;
    String name = "Coco";
    public class Inner{
        String name = "Jayden";
        public void show(){
            System.out.println(Outer.this.name);
            System.out.println(name);
            System.out.println(age);
        }
    }
    public Inner getInnerClass(){
        return new Inner();
    }
    public static void main(String[] args){
        Outer o = new Outer();
        Inner in = o.new Inner();
        in.show();
    }
}
  • Inner类定义在Outer类的内部,相当于Outer类的一个成员变量的位置,Inner类可以使用任意访问控制符,如public, protected, private等;
  • Inner类中定义的show()方法可以直接访问Outer类中的数据,而不受访问控制符的影响,如直接访问Outer类中的私有属性age;
  • 定义了成员内部类后,必须使用外部类对象来创建内部类对象,而不能直接去new 一个内部类对象,即 内部类 对象名 = 外部类对象.new 内部类();
  • 编译上面程序后,会发现产生了两个.class文件:Outer.class, Outer$Inner.class;
  • 成员内部类中不能存在任何 static 的变量和方法,可以定义常量。(因为非静态内部类是要依赖于外部类的实例,而静态变量和方法是不依赖于对象的,仅与类相关。简而言之在加载静态域时,根本没有外部类,所以在非静态内部类中不能定义静态域或静态方法,编译不通过,非静态内部类的作用域是实例级别的,然而常量是可以定义的,其在编译期间就确定了,放入到常量池中了)

补充

  1. 外部类是不能直接使用内部类的成员和方法的,可先创建内部类的对象,然后通过内部类的对象来访问其成员方法和变量;
  2. 如果外部类和内部类具有相同的成员变量和方法,内部类默认访问自己的成员变量和方法,如果要访问外部类的成员变量,可以使用this关键字,例如Outer.this.name

4.4.3 局部内部类

其作用域仅限于方法内,方法外部无法访问该内部类。一旦方法执行完毕,局部内部类就会从内存中删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Outer{
    public void Show(){
        final int a = 25;
        int b = 13;
        class Inner{
            int c = 2;
            public void print(){
                System.out.println("访问外部类:" + a);
                System.out.println("访问内部类:" + c);
            }
        }
        Inner i = new Inner();
        i.print();
    }
    public static void main(String[] args){
        Outer o = new Outer();
        o.show();
    }
} 
  • 局部内部类就像是方法里面的一个局部变量一样,是不能有public,protected以及static修饰符;
  • 只能访问方法中定义的final类型的局部变量,因为当方法被调用运行完毕之后,局部变量就已经消亡了,但是内部类对象可能还存在,直到没有被引用时才消亡。此时可能就会出现一种情况,就是内部类要访问一个不存在的局部变量。然而,使用final修饰符不仅会保持对象的引用不会改变,而且编译器还会继续维护这个对象在回调方法中的生命周期。局部内部类并不是直接调用方法传进来的参数,而是内部类将传进来的参数通过自己的构造器备份到了自己的内部,自己内部的方法调用的实际上是自己的属性而不是外部类方法的参数。使用final修饰防止被篡改数据,从而导致内部类得到的值不一致。

注意: 在JDK8版本之后,方法内部类中调用方法中的局部变量,可以不需要修饰为final,匿名内部类也是一样。JDK8之后增加了Effectively final功能。

4.4.4 匿名内部类

匿名内部类是存在于某个类的内部,但是无类名的类,适用于创建那种只需要使用一次的类。匿名内部类的创建语法有点奇怪,创建匿名内部类时会立即创建一个该类的实例,这个类定义立即消失,匿名内部类不能重复使用。

1
2
3
4
new 实现接口() | 父类构造器(实参列表)
{
    //匿名内部类的类体部分
}

由上面的定义可以看出,匿名内部类必须继承一个父类,或者实现一个接口,但是最多只能继承一个父类,或实现一个接口。匿名内部类还有以下两条规则:

  • 匿名内部类不能是抽象类,匿名内部类不能存在任何的静态变量和静态方法,因为系统在创建匿名内部类的时候,会立即创建匿名内部类的对象,它必须要实现继承的类或者实现的接口的所有抽象方法。
  • 匿名内部类不能定义构造器,由于匿名内部类没有类名,所以无法定义构造器,但是匿名内部类可以定义初始化块,可以通过实例初始化块来完成构造器需要完成的事情。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class OuterClass {
    public InnerClass getInnerClass(final int   num,String str2){
        return new InnerClass(){
            int number = num + 3;
            public int getNumber(){
                return number;
            }
        };        /* 注意:分号不能省 */
    }
    public static void main(String[] args) {
        OuterClass out = new OuterClass();
        InnerClass inner = out.getInnerClass(2, "chenssy");
        System.out.println(inner.getNumber());
    }
}
interface InnerClass {
    int getNumber();
}

4.4.5 总结

Java内部类

4.5 Java的异常体系

异常(Exception)是在程序执行过程中发生的一些不希望发生的事情,这些事情如果不被好好处理,就会导致奇怪的结果或者是程序终结。Exception Hander是那些当异常发生时处理这些异常的代码。java用try/catch来处理异常。

4.5.1 Error与Exception的区别

在Java中,所有的异常都有一个共同的祖先Throwable。Throwable指定代码中可用异常传播机制通过Java应用程序传输任何问题的共性。

Exception in Java

  • Throwable:其有两个重要的子类——Exception(异常),Error(错误),二者都是Java异常处理的重要子类,各自包含了大量子类。
  • Error(错误):是程序本身无法处理的错误,表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时JVM出现的问题。例如,Java虚拟机运行错误(Virtual Machine Error),内存溢出错误(OutOfMemoryError)等。这些异常发生时,JVM一般会选择线程终止。这些错误是不可查的,因为他们在应用程序的控制和处理能力之外,而且绝大多数是程序运行时不允许出现的状况。对于设计合理的应用程序来说,即使确实发生了错误,本质上也不应该试图去处理它所引起的异常状况。在 Java中,错误通过Error的子类描述。
  • Exception(异常): 是程序本身可以处理的异常。Exception类有一个重要的子类RuntimeException。RuntimeException类及其子类表示“JVM常用操作”引发的错误。例如,若试图使用空值对象引用、除数为0或者数组越界,则分别引发运行时异常(NullPointerException, ArithmeticException, ArrayIndexOutOfBoundException).

4.5.2 运行时异常与一般异常的异同

Java的异常被分为两大类:Checked异常和Runtime异常(运行时异常)。所有的RuntimeException类及其子类的实例被称为Runtime异常;不是RuntimeException及其子类的异常实例则被称为Checked异常。

CheckedException: Java认为Checked异常时可以被处理的异常,所以Java程序必须显示处理Checked异常。如果程序没有处理Checked异常,该程序在编译时就会发生错误,无法通过编译。例如:CloneNotSupportedException, IOException, SQLException,InterruptedException, FileNotFoundException等等。

RuntimeException: 运行时异常,发生在程序执行时。我们可以添加handler去处理这类异常,也可以不加handler不处理这类异常。例如:数组脚本越界(ArrayIndexOutOfBoundsException),空指针异常(NullPointerException)、类转换异常(ClassCastException)出现运行时异常的时候,程序会将异常一直向上抛,一直抛到遇到处理代码,如果没有catch块进行处理,到了最上层,如果是多线程就有Thread.run()抛出,如果不是多线程那么就由main.run()抛出。抛出之后,如果是线程,那么该线程也就终止了,如果是主程序,那么该程序也就终止了。(其实运行时异常的也是继承自Exception,也可以用catch块对其处理,只是我们一般不处理罢了,也就是说,如果不对运行时异常进行catch处理,那么结果不是线程退出就是主程序终止。如果不想终止,那么我们就必须捕获所有可能出现的运行时异常。如果程序中出现了异常数据,但是它不影响下面的程序执行,那么我们就该在catch块里面将异常数据舍弃,然后记录日志。如果,它影响到了下面的程序运行,那么还是程序退出比较好些。)

4.5.3 java异常处理机制的逻辑

我们知道异常对象用Throwable类来表示,其中文名表示“可抛出的”,所以通常都是在方法代码中通过throw关键字往外抛出一个异常对象。方法内抛出一个异常对象,如果是受检查的异常对象,编译器要求你要么通过try-catch捕获处理掉异常,要么通过关键字throws往外抛出异常;如果是不受检查的异常对象,则无需处理。所以,任何异常都是先抛出然后捕获处理,如果没有捕获处理则无限抛出。

1. 自定义异常

由于Java语言本身提供的异常都与语言紧密相关的异常(如类相关,方法调用相关),这类异常给出的信息有时候并不能满足业务系统的逻辑要求,常见的就是组件异常,针对这中情况我们需要在项目中自定义异常。自定义异常通过继承Exception类自定义受检查异常,通过继承RuntimeException类自定义不受检查异常。给出两类自定义异常的例子:

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 Test {
    public static void main(String[] args) {
        Test test = new Test();
        try {
            test.method1();
        } catch (DefinedCompileException e) {
            e.printStackTrace();
        }

        test.method2();
    }
    // 抛出自定义的受检查异常
    void method1() throws DefinedCompileException {
        throw new DefinedCompileException();
    }
    // 抛出自定义的不受检查异常
    void method2() {
        throw new DefinedRuntimeException();
    }

}
// 自定义受检查异常
class DefinedCompileException extends Exception {
    public DefinedCompileException() {}
    public DefinedCompileException(String message) {
        super(message);
    }
}
//自定义不受检查异常
class DefinedRuntimeException extends RuntimeException {
    public DefinedRuntimeException() {}
    public DefinedRuntimeException(String message) {
        super(message);
    }
}

从示例中可以看出,声明method1()方法抛出自定义的受检查异常,而声明method2()方法抛出自定义的不受检查异常,在test对象调用两个方法时,method1()方法要么捕获异常,要么再往外抛,method2()方法则无需捕获异常。

2. 构造异常对象的过程

method1()生成一个不带参数的DefinedCompileException对象,根据继承关系可知,最终会调用Throwable类的fillInStackTrace()方法。

1
2
3
public Throwable() {
    fillInStackTrace();
}

fillInStackTrace()方法会把当前线程栈帧的信息记录到Throwable对象中。

1
2
3
4
5
6
7
8
    public synchronized Throwable fillInStackTrace() {
        if (stackTrace != null ||
            backtrace != null /* Out of protocol state */ ) {
            fillInStackTrace(0);
            stackTrace = UNASSIGNED_STACK;
        }
        return this;
    }

fillInStackTrace(arg)是一个本地方法,返回一个Throwable对象。

1
private native Throwable fillInStackTrace(int dummy);

所以,在Java程序中,如果发生不受检查异常且没有捕获的情况下,会看到一系列的调用路径信息,这个就是线程的堆栈帧记录。

3. 异常对象的捕获

Java中通过try-catch代码块捕获异常对象,这里的捕获既可以捕获受检查异常也可以捕获不受检查异常,catche代码块可以单个捕获也能多个捕获,如下面的实例:

1
2
3
4
5
6
7
try {
    test.method1();
} catch (DefinedCompileException e) {
    e.printStackTrace();
} catch (Exception e) {
    e.printStackTrace();
}

注意,这里只要符合其中的某一个catch代码块即停止匹配,捕获的规则是从小到大,这样便于查看具体错误原因。

4. 异常对象的外抛

方法内抛出一个异常对象,此时可以不捕获,可以在方法上用关键字throws把异常抛出去,即把异常抛给调用此方法的方法,异常由上一层的方法决定是否捕获还是继续往外抛,直到捕获并处理。对于不受检查异常对象,会一直抛到线程上,直到该线程抛出异常并停止运行。

throw和throws的区别:

  • throw语句用在方法体内,表示抛出异常,由方法体内的语句处理。
  • throw 是具体向外抛出异常的动作,所以它抛出的是一个异常实例,执行 throw 一定是抛出了某种异常。
  • throws 语句是用在方法声明后面,表示如果抛出异常,由该方法的调用者来进行异常的处理。
  • throws 主要是声明这个方法会抛出某种类型的异常,让它的使用者要知道需要捕获的异常的类型。
  • throws 表示出现异常的一种可能性,并不一定会发生这种异常。

4.5.4 使用过程中注意事项

  • 不要捕获类似于Exception通用异常,而应该捕获特定异常;
  • 不要生吞异常
  • 不要直接打印堆栈,而是使用产品日志,详细的输出到日系系统里
  • throw earyly , catch later(尽早外抛,延迟捕获)
  • try-catch代码块会产生额外的开销,所以尽量捕获必要的代码,即try的范围尽量精确;
  • Java每生成一个Exception对象就会生成当前栈的快照,所以避免频繁生成Exception对象,

附录:常见的异常种类:

Error类:

  • java.lang.OutOfMemoryError:可怕的错误之一,JVM内存不足导致的Error,程序直接停止运行。
  • java.lang.StackOverflowError:最可怕的错误之一,JVM栈溢出错误,程序直接停止运行。
  • java.lang.NoClassDefFoundError:未找到类定义错误。当Java虚拟机或者类装载器试图实例化某个类,而找不到该类的定义时抛出该错误。

RuntimeException:

  • java.lang.ClassCastException: 强制类型转换异常,一般发生在向下类型转换过程中。
  • java.lang.ClassNotFoundException: 找不到类异常。当应用试图根据字符串形式的类名构造类,而在遍历CLASSPATH之后找不到对应名称的class文件时,抛出该异常。
  • java.lang.IndexOutOfBoundsException: 索引越界异常。
  • java.lang.NullPointerException: 空指针异常。
  • java.lang.NumberFormatException: 数字格式异常。
  • java.lang.ArithmeticException:算术条件异常。比如:除数为零时。
  • java.lang.ArrayIndexOutOfBoundsException:数组索引越界异常。
  • java.lang.InstantiationException:实例化异常。当试图通过newInstance()方法创建某个类的实例,而该类是一个抽象类或接口时,抛出该异常。
  • java.lang.InterruptedException:线程阻塞异常。

4.6 抽象类与接口的对比

首先,总结一下接口与抽象类的相同和区别(JDK1.8之后):

4.6.1 相同点

  1. 都可以定义方法和属性。
  2. 都可以看成是一种特殊的类,它们被设计出来的目的就是要让子类实现其中定义的抽象方法。
  3. 都可以不含有抽象方法。不含有抽象方法的接口可以作为一个标志(比如可序列化的接口Serializable)
  4. 都不能被实例化。
  5. 实现了接口的类或者继承了抽象类的子类,都必须实现定义的抽象方法。如果存在没有实现的方法,那么该类必须声明为抽象类。
  6. 都实现了多态。通过方法的动态解析特征,在运行时动态调用实现类的方法。

4.6.2 区别点

  1. 继承:接口可以实现多继承,但是接口只能继承接口。抽象类只能实现单继承,但是抽象类可以继承普通类也能继承抽象类。
  2. 方法:抽象类的方法的访问修饰符可以是任意的,并且方法可以是抽象的,也可以是不抽象的。接口定义的方法默认是 public abstract 的(可以不用显示声明)。(重点:在Java8中,接口新增了 default 修饰的默认方法实现,和 static 声明的静态方法)
  3. 类变量:抽象类变量可以有任意的变量修饰符。接口的类变量默认并且强制是 public static final (可以不用显示声明)。实现类可以继承使用,但是不能对接口变量覆盖。
  4. 静态方法和代码块:抽象类可以有静态代码块,也可以有静态方法,静态方法访问修饰符也没有限制。接口中不允许有静态代码块,接口静态方法默认并且强制是public的,并且静态方法不能被继承和实现。
  5. 构造器:抽象类可以有构造器。接口不能有构造器。
  6. 设计理念

    抽象类是对类的抽象,是对同一个类型下共有的特性抽象,是作为一系列子类的模板设计。其一般用于定义一系列类的公有功能和操作,而留下抽象方法给子类,去实现子类独有的特性。抽象类是一种自下而上的设计,先有了子类,然后提取子类公有的特性与行为,构成了抽象类。抽象类与子类是is-a关系,父类和派生的子类在概念上是相同的,存在的是父子关系,是一种重耦合。 接口是对行为的抽象。它定义了一种规范,一般用于延伸类的行为方式。接口是一种自下而上的设计,先规定行为方法,然后由类去实现这些行为,就可以成为接口的实现类。接口与实现类是like-a关系,接口与实现类的关系只是定义了行为,本质上并无实质关系,只是契约层面的关系。

4.6.3 总结

参数 抽象类 接口
默认方法 抽象类可以有默认的方法实现 java8之前,接口中不存在方法的实现
实现方式 子类使用extends关键字来继承抽象类。如果子类不是抽象类,子类需要提供抽象类中所声明方法的实现。 子类使用implements来实现接口,需要提供接口中所有声明的实现。
构造器 抽象类中可以有构造器 接口中不能
与正常Java类的区别 抽象类除了不能被实例化外,它和普通Java类没有任何区别 接口是完全不同的类型
访问修饰符 抽象方法可以有public、protected和default这些修饰符 接口方法默认修饰符是public,你不可以使用其他修饰符。
main方法 抽象类中可以有main方法并且我们可以运行它 接口中没有main方法,因此我们不能运行它。
多继承 抽象类可以继承一个类和实现多个接口 接口只可以继承一个或多个接口
速度 它比接口速度更快 接口是稍微有点慢的,因为它需要时间去寻找在类中实现的方法。
添加新方法 如果你往抽象类中添加新的方法,你可以给它提供默认的实现。因此你不需要改变你现在的代码。 如果你往接口中添加方法,那么你必须改变实现该接口的类。

4.7 Java泛型

泛型,本质就是“参数化类型”,是JDK在1.5版本引入的一个特性,在某种程度上,泛型的出现简化了我们的代码,在编译阶段保证代码的安全性。在Thinking in java中这样解释泛型:泛型实现了参数化类型的概念,使得类型可以作为参数适用于尽可能多的场景

4.7.1 泛型的引入及工作原理

在jdk1.5之前,如果要实现类似于泛型的功能,基本上都是依赖于Object,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class A {
    private Object b;
    public void setB(Object b) {
        this.b = b;
    }
    public Object getB() {
        return b;
    }
}

------------------------------------------
A a=new A();
a.setB(1);
int b=(int)a.getB();//需要做类型强转
String c=(String)a.getB();//运行时,ClassCastException

编译器检查不出来这种错误,只有在运行期间才能检查出来,此时就会出现恼人的ClassCastException,应用也挂了。所以使用Object来实现泛型的功能就要求时刻做好类型转换,否则很容易出问题。那么有没有办法将这些检查放在编译期做呢,泛型就产生了,泛型在编译期进行类型检查,问题就容易发现的多了。我们用泛型来实现一下看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class A<T> {
    private T b;
    public void setB(T b) {
        this.b = b;
    }
    public T getB() {
        return b;
    }
}
-------------------------------
A<Integer> a = new A<>(); //Java7之后后面类型可以自动推断出来,提供了菱形语法,允许省略后面<>中的类型实参
a.setB(1);
int b=a.getB();//不需要做类型强转,自动完成
String c=(String)a.getB();//编译期报错,直接编译不通过

显而易见,泛型的出现减少了很多强转的操作,同时避免了很多运行时的错误,在编译期完成检查。

泛型工作原理

java中的泛型都是编译器层面来完成的,在生成的java字节码中是不包含任何泛型中的类型信息的,使用泛型时加上的类型参数,会在编译时被编译器去掉。这个过程称为类型擦除。泛型是通过类型擦除来实现的,编译器在编译时擦除了所有泛型类型相关的信息,所以在运行时不存在任何泛型类型相关的信息(暂且这么说,实际上并不是完全擦除),例如 List 在运行时仅用一个 List 来表示,这样做的目的是为了和 Java 1.5 之前版本进行兼容。泛型擦除具体来说就是在编译成字节码时首先进行类型检查,接着进行类型擦除(即所有类型参数都用他们的限定类型替换,包括类、变量和方法)。下面给出上面例子的字节码来分析一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//定义处已经被擦出成Object,无法进行强转,不知道强转成什么
public T getB();
   Code:
      0: aload_0
      1: getfield      #23                 // Field b:Ljava/lang/Object;
      4: areturn
//调用处利用checkcast进行强转

L5 {
            aload1
            invokevirtual com/ljj/A getB()Ljava.lang.Object);
            checkcast java/lang/Integer
            invokevirtual java/lang/Integer intValue(()I);
            istore2
        }

上文中我们在调用getB方法时不需要手动做类型强转,其实并不是不需要,而是编译器给我们进行了处理,具体来讲,泛型方法的返回类型是被擦除了,并不会进行强转,而是在调用方法的地方插入了强制类型转换。

4.7.2 深入分析泛型类型擦除到底擦除了哪些信息,是全部擦除吗?

其实java虚拟机规范中为了响应在泛型类中如何获取传入的参数化类型等问题,引入了signature,LocalVariableTypeTable等新的属性来记录泛型信息,所以所谓的泛型类型擦除,仅仅是对方法的code属性中的字节码进行擦除,而原数据中还是保留了泛型信息的,这些信息被保存在class字节码的常量池中,使用了泛型的代码调用处会生成一个signature签名字段,signature指明了这个常量在常量池的地址,这样我们就找到了参数化类型,空口无凭,我们写个非常简单的demo看一下,没法再简单了,我们只写了两个函数,第一个函数入参包含泛型,第二个方法入参只是string。

1
2
3
4
5
6
public class Test2 {
    public static void mytest(List<Integer> s) {
    }
    public static void mytest(String s) {
    }
}

利用javap工具查看一下,需要添加-v参数。

1
2
3
4
5
Constant pool:
  #14 = Utf8               mytest
  #15 = Utf8               (Ljava/util/List;)V
  #16 = Utf8               Signature
  #17 = Utf8               (Ljava/util/List<Ljava/lang/Integer;>;)V

字节码信息

一目了然,可以看出来调用到了泛型的地方会添加signature和LocalVariableTypeTable,现在就明白了泛型擦除不是擦除全部,不然理解的就太狭隘了。其实,jdk提供了方法来读取泛型信息的,利用class类的getGenericSuperClass()方法我们可以在泛型类中去获取具体传入参数的类型,本质上就是通过signature和LocalVariableTypeTable来获取的。我们可以利用这些虚拟机给我们保留的泛型信息做哪些事呢?

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
public abstract class AbstractHandler<T> {
    T obj;
    public abstract void onSuccess(Class<T> clazz);
    public void handle() {
        onSuccess(getType());
    }
    private Class<T> getType() {
        Class<T> entityClass = null;
        Type t = getClass().getGenericSuperclass();
        if (t instanceof ParameterizedType) {
            Type[] p = ((ParameterizedType) t).getActualTypeArguments();
            entityClass = (Class<T>) p[0];
        }
        return entityClass;
    }
}
-------------------------------------------------
public class Test1 {
    public static void main(String[] args) {
        new AbstractHandler<Person>() {
            @Override
            public void onSuccess(Class<Person> clazz) {
                System.out.println(clazz);
            }
        }.handle();
    }
    static class Person {
        String name;
    }
}
------------------------------
输出结果class com.ljj.Test1$Person

我们来简单的分析下这段代码,定义一个抽象类AbstractHandler,提供一个回调方法onSuccess方法。然后通过一个匿名子类传入一个Person进行调用,结果在抽象类中动态的获取到了Person类型。jdk提供的api的使用基本上像getType方法所示。我们想想其实序列化的工具就是将json数据序列化为clazz对象,前提就是要传入Type的类型,这时候Type的类型获取就很重要了,我们完全可以在泛型抽象类里面来完成所有的类型获取、json序列化等工作,有些网络请求框架就是这么处理的,这也是在实际工作场景的应用。

4.7.3 泛型的定义及泛型通配符的引入

泛型是一个特性,它可以实现类型参数化,只要使用到类的地方都可以拥有这个特性;那么我们可以定义一个类,接口或者方法(因为方法参数可以使用自定义类型)拥有泛型的特性,拥有这种特性的类、接口或者方法我们称之为泛型类、泛型接口和泛型方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 泛型接口
public interface List<E> extends Collection<E>
public interface Set<E> extends Collection<E>
public interface Map<K,V>

// 泛型类
public class ArrayList<E> extends AbstractList<E> implements List<E>
public class HashSet<E> extends AbstractSet<E> implements Set<E>
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>

// 泛型方法
void add(int index, E element);  // List接口方法
<T> T[] toArray(T[] a);   // Set接口方法
V put(K key, V value);    // Map接口方法

注意:参数化类型一般用单个的大写字母来表示,JDK中用“E”,”K”或者”V”等来表示参数化类型,尽量避免小写,因为容易跟类中的属性或者变量混淆。

或许我们会有疑问,Java中不是所有的类型都继承自Object类吗,参数化类型为什么不直接用父类Object表示?我们来看一下下面的例子:

1
2
List<Integer> intList = new ArrayList<Integer>();   // a
List<Object> objList = intList;    // b

如果按照常规的Object类是Integer类的父类,所以objList引用可以指向intList引用指向的对象,所以会理所当然的认为没有问题。先别急着下结论,下面简单分析下,假如没有问题,那么下面的操作也是可以的:

1
2
objList.add(new Object());      // c
Integer num = intList.get(0);      // d

objList和intList指向同一个内存,两个引用可以同时操作这块内存,所以通过objList引用往内存中添加一个object对象,再通过initList引用获取该对象,此时获取的是Object类型的对象,而intList引用只能获取Integer类型的对象,这两者是相互矛盾的,所以上面的结论是错误的,b代码不会通过编译。即子类型和父类型的关系,在泛型特性里并不能保证,也就不能用Object类型替代类型参数化”E”了。

既然List< Object>不是所有泛型接口List的父类,那么什么是泛型接口List的父类呢?大概是无法表达它的父类,Java中用”?“替代字母”E”来表示,如List<?>是List的父类,Set<?>是Set的父类等。“?”表明类型可以匹配任何类型,称为泛型通配符。所以上面的示例可以改为:

1
2
List<Integer> intList = new ArrayList<Integer>();
List<?> objList = intList;

objList引用是intList引用的父类。但问题又会出现了,我们无法执行下面语句:

1
objList.add(new Object());   // 编译报错

按理来说,objList是父类引用,放什么都能接受,我们假设这是正确的,下面简单分析下,泛型类ArrayList< E>的add方法:

1
2
3
4
5
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

add方法参数是类型参数化”E”,假如我放进去了没有问题,那么通过objList引用取出来呢,编译器无法知道取出来的元素类型是什么,因为objList的泛型是”?”,所以在放入的过程就会编译报错。注意,可以放入null,因为null是所有类型共有的。

或许,你可能觉得泛型父类List<?>没什么用,因为它并不能放任何东西,所以,就要引出下面的上界通配符与下界通配符。

4.7.4 泛型的两种限定通配符

<? extends T><? super T> 是Java泛型中的”通配符(Willdcards)”和”边界(Bounds)”的概念。

  • <? extends T>:是指”上界通配符(Upper Bounds Willdcards)”
  • <? super T>:是指”下界通配符(Lower Bounds Willdcards)”

为什么会要使用通配符和边界?

使用泛型的过程中,经常会出现一种很变扭的情况。例如,我们有以下类,Fruit类和它的子类Apple.

1
2
class Fruit{}
class Apple extends Fruit{}

然后有一个最简单的容器:Plate类。盘子里可以放一个泛型的东西。我们可以对这个东西做最简单的”放”和”取”的动作:set()和get()方法。

1
2
3
4
5
6
7
8
class Plate<T>{
    private T item;
    public Plate(T t){
        item = t;
    }
    public void set(T t){item = t;}
    public T get(){ return item;}
}

现在我定义一个“水果盘子”,逻辑上水果盘子当然可以装苹果。

1
Plate<Fruit> p = new Plate<Apple>(new Apple());  //编译失败

但实际上Java编译器不允许这个操作。会报错:( Plate< Apple > 是无法转换成 Plate< Fruit > ,即装”苹果的盘子”是无法转换成”装水果的盘子”)

1
error: incompatible types: Plate<Apple> cannot be converted to Plate<Fruit>

实际上,在编译器认定的逻辑如下:

  • 苹果 IS-A 水果
  • 装苹果的盘子 NOT-IS-A 装水果的盘子

所以,就算容器里装的东西之间有继承关系,但容器之间是没有继承关系的。为此,为了解决这样一个问题,<? extends T><? super T> 就被提出来用于让“水果盘子”和“苹果盘子”之间发生关系。

什么是上界?

下面的代码就是上界通配符:

1
Plate<? extends Fruit>

翻译成人话就是:一个能放水果以及一切是水果子类的盘子。 Plate<? extends Fruit>Plate< Apple > 最大的区别就是: Plate<? extends Fruit>Plate< Fruit > 以及 Plate< Apple > 的基类。直接的好处,就是可以用苹果盘子给水果盘子赋值了。

1
Plate<? extends Fruit> p = new Plate<Apple>(new Apple()); //编译成功

如果把Fruit和Apple的例子扩展一下,食物分为水果和肉类,水果有香蕉和苹果,肉类有猪肉和牛肉,苹果还有两种青苹果和红苹果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//Lev 1
class Food{}

//Lev 2
class Fruit extends Food{}
class Meat extends Food{}

//Lev 3
class Apple extends Fruit{}
class Banana extends Fruit{}
class Pork extends Meat{}
class Beef extends Meat{}

//Lev 4
class RedApple extends Apple{}
class GreenApple extends Apple{}

在这个体系中,上界通配符_Plate<? extends Fruit>_覆盖下图中蓝色的区域:

上界通配符

什么是下界?

相对应的,下界通配符如下:

1
Plate<? super Fruit>

表达的就是相反的概念:一个能放水果以及一切是水果基类的盘子。 Plate<? super Fruit>Plate< Fruit > 的基类,但不是 Plate< Apple > 的基类。对应刚才那个例子, Plate<? super Fruit> 覆盖下图中红色的区域。

下界通配符

上下界通配符的副作用

边界让Java不同泛型之间的转换更容易了。但是,这样的转换也存在着一定的副作用。那就是容器的部分功能可能失效。

以刚才的Plate为例。我们可以对盘子做两件事,向盘子里set()新东西,以及从盘子里get()东西。

1
2
3
4
5
6
class Plate<T>{
    private T item;
    public Plate(T t){item=t;}
    public void set(T t){item=t;}
    public T get(){return item;}
}

上界 <? extends T> 不能往里面存,只能往外取

<? extends T> 会使往盘子里放东西的set()方法失效。但取东西get()方法还有效。例如下面例子里两个set()方法,插入Apple和Fruit都报错。

1
2
3
4
5
6
7
8
9
10
Plate<? extends Fruit> p = new Plate<Apple>(new Apple());

//不能存入任何元素
p.set(new Fruit());  //Error
p.set(new Apple());  //Error

//读取出来的东西只能存放在Fruit或它的基类里。
Fruit newFruit1 = p.get();
Object newFruit2 = p.get();
Apple newFruit3 = p.get();  //Error

原因是编译器只知道容器内是Fruit或者它的派生类,但具体是什么类型不知道。可能是Fruit,可能是Apple,也可能是Banana。编译器在看到后面用Plate赋值之后,盘子里没有被标上有“苹果”。而是标记上一个占位符: CAP#1, 来表示捕获一个Fruit或Fruit的子类,具体是什么类不知道,代号CAP#1。然后无论是是想往里插入Apple或者Meat或者Fruit编译器都不知道能不能和这个CAP#1匹配,所以就都不允许。

所以通配符<?>和类型参数的区别就在于,对编译器来说所有的T都代表同一种类型。比如下面这个泛型方法里,三个T都指代同一个类型,要么都是String,要么都是Integer。

1
public <T> List<T> fill(T... t);

但通配符<?>没有这种约束,Plate<?>单纯的就表示:盘子里放了一个东西,是什么我不知道。

下界 <? super T> 不影响往里面存,但往外取只能放在Object对象里

使用下界 <? super T> 会使从盘子里取东西的get()方法部分失效,只能存放到Object对象里。set()方法正常。

1
2
3
4
5
6
7
8
9
10
Plate<? super Fruit> p=new Plate<Fruit>(new Fruit());

//存入元素正常
p.set(new Fruit());
p.set(new Apple());

//读取出来的东西只能存放在Object类里。
Apple newFruit3=p.get();    //Error
Fruit newFruit1=p.get();    //Error
Object newFruit2=p.get();

因为下界规定了元素的最小粒度的下限,实际上放松了容器元素的类型控制。既然元素是Fruit的基类,那往里存粒度比Fruit小的都可以。但往外读取元素就费劲了,只有所有类的基类Object对象才能装下。但这样的话,元素的类型信息就全部丢失。

PESC原则

PECS(Producer Extends Consumer Super)原则:

  • 频繁往外读取内容的,适合用上界Extends。
  • 经常往里插入的,适合用下界Super。

4.7.5 泛型使用的注意事项

A.泛型类在使用过程中类型不会被改变

1
2
3
List<Integer> intList = new ArrayList<Integer>();
List<String> strList = new ArrayList<String>();
System.out.println(intList.getClass() == strList.getClass());   // true

B.泛型类的instanceOf

1
2
if (intList instanceof List<Integer>) {}   // 编译报错
if (intList instanceof List<?>) {}   // 编译通过

第一点说明,运行时并不会区分参数化类型,即类型的信息在运行时被擦除了,所以第一个判断中检查intList引用是否是一个特定类型的泛型类是没有任何意义的。第二个判断,虽然编译能通过,但也没有实际意义。

4.7.6 具体案例——编写一段泛型程序实现LRU缓存

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public class LRUCache<K, V> extends LinkedHashMap<K, V> implements Serializable {

    private final int maxCapacity;

    private static final float DEFAULT_LOAD_FACTOR = 0.75f;

    private final Lock lock = new ReentrantLock();

    public LRUCache(int maxCapacity){
        super(maxCapacity, DEFAULT_LOAD_FACTOR, true);
        this.maxCapacity = maxCapacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxCapacity;
    }

    @Override
    public boolean containsKey(Object key) {
        try{
            lock.lock();
            return super.containsKey(key);
        }finally {
            lock.unlock();
        }
    }

    @Override
    public V get(Object key) {
        try {
            lock.lock();
            return super.get(key);
        }finally {
            lock.unlock();
        }
    }

    @Override
    public V put(K key, V value) {
        try {
            lock.lock();
            return super.put(key, value);
        }finally {
            lock.unlock();
        }
    }

    @Override
    public int size() {
        try {
            lock.lock();
            return super.size();
        }finally {
            lock.unlock();
        }
    }

    @Override
    public void clear() {
        try {
            lock.lock();
            super.clear();
        }finally {
            lock.unlock();
        }
    }

    public List<Map.Entry<K, V>> getAll(){
        try {
            lock.lock();
            return new ArrayList<Map.Entry<K, V>>(super.entrySet());
        }finally {
            lock.unlock();
        }
    }
}

4.8 类加载机制及双亲委派模型

4.8.1 由一道面试题引出类加载机制

有这样一道面试的题目:

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
36
37
38
39
40
41
42
class Singleton{
    private static Singleton singleton = new Singleton();
    public static int value1;
    public static int value2 = 0;

    private Singleton(){
        value1++;
        value2++;
    }

    public static Singleton getInstance(){
        return singleton;
    }

}

class Singleton2{
    public static int value1;
    public static int value2 = 0;
    private static Singleton2 singleton2 = new Singleton2();

    private Singleton2(){
        value1++;
        value2++;
    }

    public static Singleton2 getInstance2(){
        return singleton2;
    }

}

public static void main(String[] args) {
    Singleton singleton = Singleton.getInstance();
    System.out.println("Singleton1 value1:" + singleton.value1);
    System.out.println("Singleton1 value2:" + singleton.value2);

    Singleton2 singleton2 = Singleton2.getInstance2();
    System.out.println("Singleton2 value1:" + singleton2.value1);
    System.out.println("Singleton2 value2:" + singleton2.value2);
}

先直接打印出它运行的结果:

1
2
3
4
Singleton1 value1 : 1 
Singleton1 value2 : 0 
Singleton2 value1 : 1 
Singleton2 value2 : 1

在介绍完本章内容之后,我们再来分析一下这个问题。

4.8.2 类加载机制

类从被加载到虚拟机内存开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这七个阶段。其中,验证、准备和解析这三个部分统称为连接。

类的加载时机

其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的“开始”(仅仅指的是开始,而非执行或者结束,因为这些阶段通常都是互相交叉的混合进行,通常会在一个阶段执行的过程中调用或者激活另一个阶段),而解析阶段则不一定(它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定。

1.加载

加载主要是将.class文件(并不一定是.class,可以说zip包,网络中获取)中的二进制字节流读入到JVM中。在此阶段,JVM主要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流;
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中生成一个该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

加载阶段即可以使用系统提供的类加载器在完成,也可以由用户自定义的类加载器来完成。加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

2.连接

2.1 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。Java语言本身是相对安全的语言,使用Java编码是无法做到如访问数组边界以外的数据、将一个对象转型为它并未实现的类型等,如果这样做了,编译器将拒绝编译。但是,Class文件并不一定是由Java源码编译而来,可以使用任何途径,包括用十六进制编辑器(如UltraEdit)直接编写。如果直接编写了有害的“代码”(字节流),而虚拟机在加载该Class时不进行检查的话,就有可能危害到虚拟机或程序的安全。

不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:文件格式验证、元数据验证、字节码验证和符号引用验证

  • 文件格式验证:是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型……该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。
  • 元数据验证:是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法等。
  • 字节码验证:主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
  • 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。

2.2 准备

准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

1
public static int value=123;//在准备阶段value初始值为0 。在初始化阶段才会变为123。

2.3 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够给无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。
3.初始化

类初始化是类加载过程中的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码。

初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。

上述过程可以使用下面的脑图进行概括:

类加载过程

何时开始类的初始化?

  1. 创建类的实例。为某个类创建实例包括:使用new操作符来创建实例,通过反射来创建实例、通过反序列化来创建实例。
  2. 访问类的静态变量时。(除了常量,即被final修饰的静态变量外,因为常量是一种特殊的变量,编译器把他们当作值(value)而不是域(field)来对待。如果你的代码中用到了常变量(constant variable),编译器并不会生成字节码来从对象中载入域的值,而是直接把这个值插入到字节码中。因此即使程序使用该静态变量,也不会导致该类的初始化。反之,如果final类型的静态Field的值不能在编译时确定下来,则必须等到运行时才可以确定该变量的值,如果通过该类来访问它的静态变量,则会导致该类被初始化)
  3. 访问类的静态方法时。
  4. 使用反射Class.forName(“xxxx”)对类进行反射调用的时候,该类需要初始化。
  5. 初始化一个类的时候,有父类,先初始化父类(注:1. 接口除外,父接口在调用的时候才会被初始化;2.子类引用父类静态字段,只会引发父类初始化);
  6. 虚拟机启动时,定义了main()方法的那个类先初始化;
  7. 当使用JDK1.7的动态语言支持时,如果一个java.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

以上情况称为称对一个类进行“主动引用”,除此种情况之外,均不会触发类的初始化,称为“被动引用”。

注意:接口的加载过程与类的加载过程稍有不同。接口中不能使用static{}块。当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化。

被动引用例子

  • 子类调用父类的静态变量,子类不会被初始化。只有父类被初始化。对于静态字段,只有直接定义这个字段的类才会被初始化。
  • 通过数组定义来引用类,不会触发类的初始化。
  • 访问类的常量,不会初始化类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class SuperClass
{
    static {
        System.out.println("superclass init");
    }
    public static int value = 123;
    public static final String HELLOWORLD = "hello world";
}
 
class SubClass extends SuperClass
{
    static {
        System.out.println("subclass init");
    }
}
 
public class Test
{
    public static void main(String[] args) {
        System.out.println(SubClass.value);//被动应用1
        SubClass[] sca = new SubClass[10];//被动引用2
        System.out.println(SuperClass.HELLOWORLD); //被动引用3
    }
}

程序运行结果:

1
2
3
superclass init
123
hello world
4.题目分析

来分析一下开头给出的面试题:(静态变量的初始化是根据在类中定义的顺序进行的)

Singleton输出结果: 1 0

原因:

1
2
3
4
5
- 首先执行main中的Singleton singleton = Singleton.getInstance(); 
- 类的加载:加载类Singleton
- 类的验证
- 类的准备为静态变量分配内存设置默认值这里为singleton(引用类型)设置为null,value1value2(基本数据类型)设置默认值0
- 类的初始化(按照赋值语句进行修改)执行private static Singleton singleton = new Singleton();执行Singleton的构造器value1++;value2++;此时value1value2均等于1执行public static int value1;执行public static int value2 = 0;此时value1 = 1; value2 = 0;

Singleton2输出结果:1 1

原因:

1
2
3
4
5
- 首先执行main中的Singleton2 singleton2 = Singleton2.getInstance2(); 
- 类的加载加载类Singleton2 
- 类的验证
- 类的准备为静态变量分配内存设置默认值这里为value1,value2基本数据类型设置默认值0,singleton2(引用类型)设置为null
- 类的初始化(按照赋值语句进行修改):执行public static int value2 = 0;此时value2=0(value1不变,依然是0)执行private static Singleton2 singleton2 = new Singleton2();执行Singleton2的构造器value1++;value2++;此时value1value2均等于1即为最后结果

4.8.3 类加载器

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载如JVM中,同一个类就不会再载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)和(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的。

JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:

  1. 根类加载器(bootstrap class loader):它用来加载Java的核心类,是用原生代码来实现的,并不继承自java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
  2. 扩展类加载器(extensions class loader):它负责加载JRE的扩展目录,负责将Java_Home /lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。由Java语言实现,父类加载器为null。
  3. 系统类加载器(system class loader):被称为系统类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH变量中所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父类加载器。由Java语言实现,父类加载器为ExtClassLoader。

除此之外,还有自定义的类加载器,它们之间的层次关系被称为类加载器的双亲委派模型。该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)。后面会详细地讲解双亲委派模型的工作原理。

类加载器之间的层次关系

JVM的类加载机制主要有以下3种:

  1. 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另一个类加载器来载入。
  2. 双亲委派:所谓双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗地讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载;
  3. 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜索该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这也是为什么修改了Class文件后,必须重新启动JVM,程序所做的修改才会生效的原因。

类加载器加载Class大致要经过如下8个步骤:

  1. 检测此Class是否被载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步;
  2. 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步;
  3. 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步;
  4. 请求使用根类加载器去加载目标类,如果载入成功则跳至第8步,否则接着执行第7步;
  5. 当前类加载器尝试去寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步;
  6. 从文件中载入Class,成功后跳至第8步;
  7. 抛出ClassNotFoundException异常;
  8. 返回对应的java.lang.Class对象。

4.8.4 双亲委派机制

双亲委派机制,其工作原理就是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器(bootstrap class loader),如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

我们来具体看一下双亲委派模型的代码实现:

  • 首先检查类是否被加载,没有则调用父类加载器的loadClass()方法;
  • 若父类加载器为空,则默认使用启动类加载器作为父加载器;
  • 若父类加载失败,抛出ClassNotFoundException 异常后,再调用自己的findClass() 方法。
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
36
37
38
39
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 1.检查类是否被加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        //2 没有则调用父类加载器的loadClass方法
                        c = parent.loadClass(name, false);
                    } else {
                        //3 若父类加载器为空,则默认使用启动类加载器来作为父加载器
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 4. ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class. 调用自己的findClass()方法
                    long t1 = System.nanoTime();
                    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类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

具体的ClassLoader源码分析可以参考这篇文章: ClassLoader双亲委派机制源码分析

4.8.5 破坏双亲委派模型

双亲委派模型很好的解决了各个类加载器加载基础类的统一性问题。即越基础的类由越上层的加载器进行加载。但是若加载的基础类中需要回调用户代码,而这时顶层的类加载器无法识别这些用户代码,怎么办?这时就需要破坏双亲委派模型了。

下面给出两个例子来讲解破坏双亲委派模型的过程:

1.JNDI破坏双亲委派模型

JNDI是java标准服务,它的代码由启动类加载器去加载。但是JNDI需要回调独立厂商实现的代码,而类加载器无法识别这些回调代码(SPI)。为了解决这个问题,引入了一个线程上下文类加载器。可通过Thread.setContextClassLoader()设置。利用线程上下文类加载器去加载所需要的SPI代码,即父类加载器请求子类加载器去完成类加载的过程,而破坏了双亲委派模型。

2.Spring破坏双亲委派模型

Spring要对用户程序进行组织和管理,而用户程序一般放在WEB-INF目录下,由WebAppClassLoader类加载器加载,而Spring由Commom类加载器或Shared类加载器加载。那么Spring是如何访问WEB-INF下的用户程序呢?

Spring使用线程上下文类加载器来解决这个问题。Spring加载类所用的classLoader都是通过Thread.currentThread().getContextClassLoader()获取的。当线程创建时会默认创建一个AppClassLoader类加载器(对应Tomcat中的WebAppclassLoader类加载器): setContextClassLoader(AppClassLoader)。 利用这个来加载用户程序。即任何一个线程都可通过getContextClassLoader()获取到WebAppclassLoader。

参考博文

JVM(四)—一道面试题搞懂JVM类加载机制

【Java面试题】之类加载:从面试题分析Java类加载机制

jvm之java类加载机制和类加载器(ClassLoader)的详解

Java虚拟机类加载机制和双亲委派模型

4.9 java重载(Overload)与重写(Override)的区别

方法的重载和重写都是java实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性.

序号 重写(Override) 重载(Overloading)
类的数量 父子类、接口、实现类 本类
方法名称 一致 一致
参数列表 一定不能修改 必须修改
返回类型 一定不能修改 可以修改
异常 可以减少或删除,但不能扩展 可以修改

4.9.1 重载(Overloading)

重载发生在本类中,方法名相同,参数列表不同。其与返回值无关,只和方法名,参数列表,参数的类型有关。每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。

重载规则:

  • 被重载的方法必须改变参数列表(参数个数或类型不一样或者参数顺序不同);
  • 被重载的方法可以改变返回类型;
  • 被重载的方法可以改变访问修饰符;
  • 被重载的方法可以声明新的或更广的检查异常;
  • 方法能够在同一个类中或者在一个子类中被重载。
  • 无法以返回值类型作为重载函数的区分标准。

其实简单而言,重载就是对于不同的情况写不同的方法。例如,同一个类中,写不同的构造函数用于初始化不同的参数。

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
public class Test1 {
    public void out(){
        System.out.println("参数"+null);
    }
    //参数数目不同
    public void out(Integer n){
        System.out.println("参数"+n.getClass().getName());
    }
 
    //参数类型不同
    public void out(String string){
        System.out.println("参数"+string.getClass().getName());
    }
 
    public void out(Integer n ,String string){
        System.out.println("参数"+n.getClass().getName()+","+string.getClass().getName());
    }
    //参数顺序不同
    public void out(String string,Integer n){
        System.out.println("参数"+string.getClass().getName()+","+n.getClass().getName());
    }
    
    public static void main(String[] args) {
        Test1 test1 = new Test1();
        test1.out();
        test1.out(1);
        test1.out("string");
        test1.out(1,"string");
        test1.out("string",1);
    }
}

4.9.2 重写(Override)

重写发生在父类子类之间,比如所有类都是继承与Object类的,Object类中本身就有equals,hashcode,toString方法等.在任意子类中定义了重名和同样的参数列表就构成方法重写。一般都是表示子类和父类之间的关系,其主要的特征是:方法名相同,参数相同,但是具体的实现不同。

重写规则:

  • 参数列表必须完全与被重写方法的相同;
  • 返回类型与被重写方法的返回类型可以不相同,但是必须是父类返回值的派生类(java5 及更早版本返回类型要一样,java7 及更高版本可以不同);
  • 访问权限不能比父类中被重写的方法的访问权限更低。例如:如果父类的一个方法被声明为public,那么在子类中重写该方法就不能声明为protected。
  • 父类的成员方法只能被它的子类重写。
  • 声明为final的方法不能被重写。
  • 声明为static的方法不能被重写,但是能够被再次声明。
  • 子类和父类在同一个包中,那么子类可以重写父类所有方法,除了声明为private和final的方法。
  • 子类和父类不在同一个包中,那么子类只能够重写父类的声明为public和protected的非final方法。
  • 重写的方法能够抛出任何非强制异常,无论被重写的方法是否抛出异常。但是,重写的方法不能抛出新的强制性异常,或者比被重写方法声明的更广泛的强制性异常,反之则可以。
  • 构造方法不能被重写。
  • 如果不能继承一个方法,则不能重写这个方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Test{
    public void out(){
        System.out.println("我是父类方法");
    }
}
 
public class Test1 extends Test{
    @Override
    //方法签名完全一致
    public void out() {
        System.out.println("我是重写后的子类方法");
    }
 
    public static void main(String[] args) {
        Test test = new Test();
        test.out();
        test = new  Test1();
        test.out();
    }
}

4.9.3 Java虚拟机角度分析多态

我们的程序在运行的时候,底层虚拟机是如何去实现动态调用?我们先从java虚拟机讲起,给出java虚拟机执行java程序的过程图如下:

JVM执行程序流程

从图中我们可以看出,我们的java文件要想运行,需要通过java编译器编译成.class文件,然后通过类装载器讲.class文件装载到JVM中,最后才是执行。而且JVM分了五个区域,那么在代码中定义的那些多态方法存到了哪个地方呢?为此我们还需要对这块内存区域进行一个分析:(这里补充一下,java 1.7把运行时常量池移到了java堆中,并且java1.8之后没有永久代的概念,存放类相关信息被放在元空间里)

JVM内存区域

多态方法其实就存放在方法区中,java堆里面存放的是我们建立的一个个实例对象,方法区存放的就是类的类型等信息。而且这个方法区中的类型信息跟在堆中存放的class对象是不同的。在方法区中,这个class的类型信息只有唯一的实例(所以方法区是各个线程共享的内存区域),而在堆中可以有多个该class对象。也就是说方法区的类型信息就是像一个模板,那些class对象就好比通过这些模板创建的一个个实例。

给出下面一个具体案例分析:

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
public class Father{
    public void dealHouse(){
        System.out.println("father deal with house");
    }
}

public class SonA extends Father{
    @Override
    public void dealHouse(){
        System.out.println("SonA deal with house");
    }
}

public class SonB extends Father{
    @Override
    public void dealHouse(){
        System.out.println("SonB deal with house");
    }
}

public class Test {
    public static void main(String[] args){
        Father father = new Father();
        Father sonA = new SonA();
        Father sonB = new SonB();
        sonA.dealHouse();
        sonB.dealHouse();
    }
}

我们知道当程序运行Father sonA = new SonA();其实这里就出现了多态,因为编译的时候看到的是Father类型,但是new出来一个SonA类,两种类型还不一样。我们来看看其在内存里是如何保存的:

  • Father sonA是一个引用类型,存在了java虚拟机栈的本地变量表中;
  • new SonA其实创建了一个实例对象,存储在了java堆中;
  • SonA的类型数据存在方法区中。

内存结构

reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据,定位流程如下:

  • 虚拟机通过reference(Father的引用)查询java栈中的本地变量表,得到堆中的对象类型数据的指针,
  • 通过到对象的指针找到方法区中的对象类型数据
  • 查询方法表定位到实际类(SonA类)的方法运行。

我们来看一下父子类在方法表中是如何保存的:

父子类方法表中存储过程

很明显每一个类都会有一个方法表,子类中不同的方法指向不同的类型信息。继承自Object的就指向Object,继承自Father的就指向Father(也就是包含了父类的方法dealHouse)。

可能我们到这就迷糊了,既然子类的dealHouse方法其实是父类Father的,那么为什么会执行子类的dealHouse方法呢?别着急往下看。这是java虚拟机区分多态方法(实现动态调用)的精华所在。

当Son类的方法表会有一个指向Father类dealHouse方法的指针,同时也有一个指向自己dealHouse方法的指针,这时候,新的数据会覆盖原有的数据,也就是说原来指向Father.dealHouse的那个引用会被替换成指向Son.dealHouse的引用(占据原来表中的位置)。

上述讲述的其实是对继承实现的多态的一种分析,对接口实现的,会有着不一样的理解。Java虚拟机 对于接口方法的调用是采用搜索方法表的方式,如,要在Father接口的方法表中找到dealHouse()方法,必须搜索Father的整个方法表。从效率上来说,接口方法的调用总是慢于类方法的调用的。

最后,总结一下,类调用是根据多态方法在方法表中的位移量,而接口调用是根据搜索整个方法表来实现的。

4.10 Object类的方法总结

Object类是Java中所有类的基类,其位于java.lang包中,其一共有13个方法。其按照用途可以分为以下几种:

  • 构造函数方法
  • hashCode()与equals()方法用来判断对象是否相同
  • wait(),wait(long),wait(long,int),notify(),notifyAll()线程等待或唤醒
  • toString()和getClass()
  • clone(),对象的浅复制
  • finalize(),与Java垃圾回收机制有关

Object类

其中,object.c中包含了下面:

1
2
3
4
5
6
7
static JNINativeMethod methods[] = {
{"hashCode", "()I", (void *)&JVM_IHashCode},
{"wait", "(J)V", (void *)&JVM_MonitorWait},
{"notify", "()V", (void *)&JVM_MonitorNotify},
{"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
{"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
};

来简单总结一下各个方法:

  • getClass():一个final方法,不可重写,其作用是返回此Object对象的类对象/运行时类对象Class。
  • hashCode():获取对象的哈希值,一般情况下是根据对象的地址、字符串或者数字计算。
  • equals():判断两个对象是否相等。
  • clone():这是一个protected方法,实现对象的浅拷贝。只有当类实现Cloneable接口才可以调用该方法,否则抛出CloneNotSupportedException异常。
  • toString():返回一个能够表示该对象的字符串,其默认返回类对象名加上对象的哈希码。一般来说,字符串应该是简明而有意义的,且尽量所有的子类都应该重写该方法。
  • notify():唤醒在该对象上等待的某个线程,如果有多个线程在该对象上等待,那么按照一定的算法唤醒其中一个。
  • notifyAll():唤醒在该对象上等待的所有线程。
  • wait():使当前线程在该对象上等待。
  • finalize(): 这是一个protected方法。当JVM准备对此对形象所占用的内存空间进行垃圾回收前,该方法将被调用,程序员可以通过该方法释放JVM无法管理的内存以避免内存泄漏。一般而言,该方法不由我们来主动去调用。

4.10.1 详述finalize()作用

  • java的GC只负责对象内存相关的清理,所有其他资源的清理必须由程序员手动编写,否则可能会导致内存泄漏,因此某些对象会有close()方法。
  • 而finalize()是确定的当对象被GC回收时必然调用的方法,假设有资源持续在整个对象的生命周期里,可以将额外的资源在finalize()里进行回收;
  • finalize()可以被用户自己调用,但这种调用不会导致对象被销毁;
  • GC时,finalize()里抛出的异常,如未被捕获处理,只会导致该对象的finalize()执行退出,而不会导致GC终止。

4.10.2 wait()和notify()方法的底层实现

Object作为java中所有对象的基类,其重要性不言而喻,其中wait()和notify()方法的实现为多线程协作提供了保障。我们先来看一个例子,看看如何使用者两个方法:

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
36
37
38
39
40
41
42
43
44
45
46
47
public class WaitNotifyCase {
    public static void main(String[] args) {
        final Object lock = new Object();

        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("thread A is waiting to get lock");
                synchronized (lock) {
                    try {
                        System.out.println("thread A get lock");
                        TimeUnit.SECONDS.sleep(2);
                        System.out.println("thread A do wait method");
                        lock.wait();
                        System.out.println("wait end");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();

        //保证线程B肯定后于线程A拿到锁
        try{
            TimeUnit.SECONDS.sleep(1);
        }catch (InterruptedException e){
            e.printStackTrace();
        }

        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("thread B is waiting to get lock");
                synchronized (lock) {
                    System.out.println("thread B get lock");
                    try {
                        TimeUnit.SECONDS.sleep(5);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    lock.notify();
                    System.out.println("thread B do notify method");
                }
            }
        }).start();
    }
}

执行结果:

1
2
3
4
5
6
7
thread A is waiting to get lock
thread A get lock
thread B is waiting to get lock
thread A do wait method
thread B get lock
thread B do notify method
wait end

在上面的代码中,由同一个lock对象调用wait,notify方法:

  • 当线程A执行wait方法时,该线程会被挂起;
  • 当线程B执行notify方法时,会唤醒一个被挂起的线程A。

观察上面的代码,我们可能会存在下面的疑惑:

  • 进入wait/notify方法之前,为什么要获取synchronized锁?
  • 线程A获取了synchronized锁,执行wait方法并挂起,线程B又如何再次获取锁?
1.为什么要获取synchronized锁?

注意,这两个方法的使用必须是在synchronized同步块中,并且在当前对象的同步块中,如果在A对象的方法中调用B对象的wait或notify方法,虚拟机会抛出IllegalMonitorStateException(非法的监视器异常),因为你这个线程持有的监视器和调用的监视器不是同一个对象。

前面介绍过synchronized关键字,我们知道synchronized修饰的代码块通过javap生成的字节码中包含“monitorenter”和“monitorexit”指令。执行monitorenter指令可以获取到对象的monitor,而lock.wait()方法通过调用native方法wait(0)实现,其中接口注释存在下面一句话:

1
The current thread must own this object's monitor.

其表示线程执行lock.wait()方法时,必须持有该lock对象的monitor。如果wait()方法在synchronized代码块中执行,很明显该线程已经持有了lock对象的monitor.

在回答第二个问题之前,我们先来具体了解一下monitor.

2.什么是monitor?

管程,英文名是Monitor,也常被翻译成“监视器”。我们知道,操作系统在面对进程/线程同步的时候,支持了一些同步原语,其中信号量(semaphore)和互斥量(mutex)是最重要的同步原语。然而,当我们使用基本的mutex进行并发控制时,需要程序员非常小心地控制 mutex 的 down 和 up 操作,否则很容易引起死锁等问题。为了更容易地编写出正确的并发程序,所以在 mutex 和 semaphore 的基础上,提出了更高层次的同步原语 monitor,不过需要注意的是,操作系统本身并不支持 monitor 机制,实际上,monitor 是属于编程语言的范畴,当你想要使用 monitor 时,先了解一下语言本身是否支持 monitor 原语,例如 C 语言它就不支持 monitor,Java 语言支持 monitor。

monitor 的重要特点是,同一个时刻,只有一个 进程/线程 能进入 monitor 中定义的临界区,这使得 monitor 能够达到互斥的效果。但仅仅有互斥的作用是不够的,无法进入 monitor 临界区的 进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。显然,monitor 作为一个同步工具,也应该提供这样的管理 进程/线程 状态的机制。想想我们为什么觉得 semaphore 和 mutex 在编程上容易出错,因为我们需要去亲自操作变量以及对 进程/线程 进行阻塞和唤醒。monitor 这个机制之所以被称为“更高级的原语”,那么它就不可避免地需要对外屏蔽掉这些机制,并且在内部实现这些机制,使得使用 monitor 的人看到的是一个简洁易用的接口。

monitor基本元素

monitor机制需要几个元素来配合,分别是:

  • 临界区
  • monitor对象及锁
  • 条件变量以及定义在monitor对象上的wait, signal操作

使用monitor机制的目的是为了互斥进入临界区,为了做到能阻塞无法进入临界区的进程/线程,还需要一个monitor Object来协助,这个monitor object内部会有相应的数据结构,例如列表,来保存被阻塞的线程;同时由于monitor机制本质上是基于mutex这种基本原语的,所以monitor object还必须维护一个基于mutex的锁。

此外,为了能够在适当的时候阻塞和唤醒进程/线程,还需要引入一个条件变量,这个条件变量用来决定什么时候是“适当的时候”,这个条件可以来自程序代码的逻辑,也可以是在 monitor object 的内部,总而言之,程序员对条件变量的定义有很大的自主性。不过,由于 monitor object 内部采用了数据结构来保存被阻塞的队列,因此它也必须对外提供两个 API 来让线程进入阻塞状态以及之后被唤醒,分别是 wait 和 notify。

3. monitor在Java中的实现

在HotSpot虚拟机中,monito采用ObjectMonitor实现:

ObjectMonitor结构

每个线程都会有两个ObjectMonitor对象列表,分别为free和used列表,如果当前free列表为空,线程将向全局global list请求分配ObjectMonitor。

ObjectMonitor对象中有两个队列:_WaitSet和_EntryList,用来保存ObjectWaiter对象列表。_owner指向获得ObjectMonitor对象的线程。

ObjectMonitor

当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 monitor 机制中称为条件变量。

ObjectWaiter ObjectWaiter

ObjectWaiter对象是双向链表结构,保存了_thread(当前线程)以及当前的状态TState等数据, 每个等待锁的线程都会被封装成ObjectWaiter对象。

wait方法实现

lock.wait()方法最终通过ObjectMonitor的void wait(jlong millis, bool interruptable, TRAPS);实现:

  1. 将当前线程封装成ObjectWaiter对象node;

step1

  1. 通过ObjectMonitor::AddWaiter方法将node添加到_WaitSet列表中:

step2

  1. 通过ObjectMonitor::exit方法释放当前的ObjectMonitor对象,这样其它竞争线程就可以获取该ObjectMonitor对象。

step3

  1. 最终底层的park方法会挂起线程。

notify方法实现

lock.notify()方法最终通过ObjectMonitor的void notify(TRAPS)实现:

  1. 如果当前_WaitSet为空,即没有正在等待的线程,则直接返回;
  2. 通过ObjectMonitor::DequeueWaiter方法,获取_WaitSet列表中的第一个ObjectWaiter节点,实现也很简单。(在jdk的notify方法注释是随机唤醒一个线程,其实是第一个ObjectWaiter节点)

notify()

  1. 根据不同的策略,将取出来的ObjectWaiter节点,加入到_EntryList或则通过Atomic::cmpxchg_ptr指令进行自旋操作cxq。

notifyAll方法实现

lock.notifyAll()方法最终通过ObjectMonitor的void notifyAll(TRAPS)实现:通过for循环取出_WaitSet的ObjectWaiter节点,并根据不同策略,加入到_EntryList或则进行自旋操作。

从JVM的方法实现中,可以发现:notify和notifyAll并不会释放所占有的ObjectMonitor对象,其实真正释放ObjectMonitor对象的时间点是在执行monitorexit指令,一旦释放ObjectMonitor对象了,entry set中ObjectWaiter节点所保存的线程就可以开始竞争ObjectMonitor对象进行加锁操作了。

总结

就针对于wait方法而言,其简单的内部实现总结如下(将当前线程放入wait set,等待被唤醒,并放弃lock对象上的所有同步声明。):

  • 将当前线程封装成ObjectWaiter对象node;
  • 通过ObjectMonitor::AddWaiter方法将node添加到_WaitSet列表中;
  • 通过ObjectMonitor::exit方法释放当前的ObjectMonitor对象,这样其它竞争线程就可以获取该ObjectMonitor对象。
  • 最终底层的park方法会挂起线程。

(最后与之对应的notify方法)会随机唤醒_WaitSet中随机一个线程。

参考文章

JVM源码分析之Object.wait/notify实现

Java 中的 Monitor 机制

Java的wait()、notify()学习三部曲之一:JVM源码分析

java的Object里wait()实现原理

Java中Object.wait()、Object.notify()/notifyAll()底层原理

4.11 深拷贝与浅拷贝

首先明白的是,浅拷贝与深拷贝都是针对一个已有对象的操作。我们先来了解下浅拷贝与深拷贝的概念。

在Java中,除了基本数据类型(元类型)之外,还存在类的实例对象这个引用数据类型。而一般使用’=’号做赋值操作的时候,对于基本类型而言,实际上拷贝的是它的值,但是对于对象而言,其实赋值的只是这个对象的引用,将原对象的引用传递过去,它们实际上还是指向的同一个对象。

而浅拷贝和深拷贝就是在这个基础之上做的区分,如果在拷贝这个对象的时候,只对基本数据类型进行了拷贝,而对引用数据类型只是进行了引用的传递,而没有真实地创建一个新的对象,则认为是浅拷贝。反之,在对引用数据类型进行拷贝的时候,创建了一个新的对象,并且复制其内的成员变量,则认为是深拷贝。

所以,所谓的浅拷贝与深拷贝,只是在拷贝对象的时候,对于类的实例对象这种引用数据类型的不同操作而已。

注意,浅拷贝与深拷贝都是对于对象的属性而言的,clone()方法实则是真的创建了一个新的对象。

1. 浅拷贝

被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。换言之,浅拷贝仅仅复制所拷贝的对象,而不复制它所引用的对象。

浅拷贝

实现方式:浅拷贝的实现只需要实现Cloneable接口,再重写Object类的clone方法时,仍然调用super.clone()方法即可。Object类的clone方法默认就是浅拷贝。

给出一个浅拷贝的例子如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
public class Book implements Cloneable {

    private int id;
    private String name;
    private BookBorrow bookBorrow;

    public Book(){

    }

    public Book(int id, String name, BookBorrow bookBorrow) {
        this.id = id;
        this.name = name;
        this.bookBorrow = bookBorrow;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public BookBorrow getBookBorrow() {
        return bookBorrow;
    }

    public void setBookBorrow(BookBorrow bookBorrow) {
        this.bookBorrow = bookBorrow;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        //浅拷贝案例
        Book book = (Book)super.clone();
        return book;
    }

    @Override
    public String toString() {
        return "BOOK[id="+id+",name="+name+",bookBorrow:"+bookBorrow+"]";
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        BookBorrow bookBorrow = new BookBorrow(1,1);
        Book book1 = new Book(1,"基础系列1",bookBorrow);
        Book book2 = (Book) book1.clone();

        System.out.println("改变之前:");
        System.out.println("图书1:" + book1.toString());
        System.out.println("图书2:" + book2.toString());

        book2.setName("基础系列2");
        book2.bookBorrow.setId(2);
        book2.bookBorrow.setBorstate(2);

        System.out.println("改变之后:");
        System.out.println("图书1:" + book1.toString());
        System.out.println("图书2:" + book2.toString());
    }


public class BookBorrow implements Cloneable {

    private int id;

    private int borstate;

    public BookBorrow(int id, int borstate){
        this.id = id;
        this.borstate = borstate;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getBorstate() {
        return borstate;
    }

    public void setBorstate(int borstate) {
        this.borstate = borstate;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return (BookBorrow)super.clone();
    }

    @Override
    public String toString() {
        return "BookBorrow[id=" + id + ",borstate=" + borstate + "]";
    }
}

输出结果:

1
2
3
4
5
6
改变之前:
图书1:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
图书2:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
改变之后:
图书1:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=2,borstate=2]]
图书2:BOOK[id=1,name=基础系列2,bookBorrow:BookBorrow[id=2,borstate=2]]

2. 深拷贝

对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。

深拷贝

对于深拷贝的实现,比较常用的方案有两种:

  1. 序列化(serialization)这个对象,再反序列化回来,就可以得到这个新的对象。
  2. 继续利用clone()方法,我们可以对于对象其内的引用类型的变量,再进行一次clone()。

继续上面的例子,修改如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
public class Book implements Cloneable {

    private int id;
    private String name;
    private BookBorrow bookBorrow;

    public Book(){

    }

    public Book(int id, String name, BookBorrow bookBorrow) {
        this.id = id;
        this.name = name;
        this.bookBorrow = bookBorrow;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public BookBorrow getBookBorrow() {
        return bookBorrow;
    }

    public void setBookBorrow(BookBorrow bookBorrow) {
        this.bookBorrow = bookBorrow;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        Book book = (Book)super.clone();
        // 这里注释掉就是浅拷贝,否则就是深拷贝
        book.bookBorrow = (BookBorrow)bookBorrow.clone();
        return book;
    }

    @Override
    public String toString() {
        return "BOOK[id="+id+",name="+name+",bookBorrow:"+bookBorrow+"]";
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        BookBorrow bookBorrow = new BookBorrow(1,1);
        Book book1 = new Book(1,"基础系列1",bookBorrow);
        Book book2 = (Book) book1.clone();

        System.out.println("改变之前:");
        System.out.println("图书1:" + book1.toString());
        System.out.println("图书2:" + book2.toString());

        book2.setName("基础系列2");
        book2.bookBorrow.setId(2);
        book2.bookBorrow.setBorstate(2);

        System.out.println("改变之后:");
        System.out.println("图书1:" + book1.toString());
        System.out.println("图书2:" + book2.toString());
    }
}

public class BookBorrow implements Cloneable {

    private int id;

    private int borstate;

    public BookBorrow(int id, int borstate){
        this.id = id;
        this.borstate = borstate;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getBorstate() {
        return borstate;
    }

    public void setBorstate(int borstate) {
        this.borstate = borstate;
    }

    @Override
    protected Object clone() throws CloneNotSupportedException {
        return (BookBorrow)super.clone();
    }

    @Override
    public String toString() {
        return "BookBorrow[id=" + id + ",borstate=" + borstate + "]";
    }
}

输出结果为:

1
2
3
4
5
6
改变之前:
图书1:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
图书2:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
改变之后:
图书1:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
图书2:BOOK[id=1,name=基础系列2,bookBorrow:BookBorrow[id=2,borstate=2]]

顺便给出序列化clone的一种方式:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class CloneUtils {

    public static <T extends Serializable> T clone(T obj){

        T cloneObj = null;
        try {
            //写入字节流
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            ObjectOutputStream obs = new ObjectOutputStream(out);
            obs.writeObject(obj);
            obs.close();

            //分配内存,写入原始对象,生成新对象
            ByteArrayInputStream ios = new ByteArrayInputStream(out.toByteArray());
            ObjectInputStream ois = new ObjectInputStream(ios);
            //返回生成的新对象
            cloneObj = (T) ois.readObject();
            ois.close();
        } catch (Exception e) {
            e.printStackTrace();
        }

        return cloneObj;
    }
}

public class BookBorrow implements Serializable{
    ...
    //去掉clone方法,继承Serializable

}

public class Book implements Serializable {
    ...
    //去掉clone方法,继承Serializable

    public static void main(String[] args) throws CloneNotSupportedException {

        BookBorrow bookBorrow = new BookBorrow(1,1);
        Book book1 = new Book(1,"基础系列1",bookBorrow);
        Book book2 = CloneUtils.clone(book1);

        System.out.println("图书1:" + book1.toString());
        System.out.println("图书2:" + book2.toString());

        book2.setName("基础系列2");
        book2.setBookBorrow(new BookBorrow(5,5));

        System.out.println("图书1:" + book1.toString());
        System.out.println("图书2:" + book2.toString());

    }
}

执行结果:

1
2
3
4
图书1:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
图书2:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
图书1:BOOK[id=1,name=基础系列1,bookBorrow:BookBorrow[id=1,borstate=1]]
图书2:BOOK[id=1,name=基础系列2,bookBorrow:BookBorrow[id=5,borstate=5]]

序列化克隆无需继承,通过序列化工具类可实现深克隆同等效果。然而克隆没有银弹,序列化这种方式在效率上比之原clone有所不如。

3. Object clone的实际用途

  1. 精心设计一个浅克隆对象被程序缓存,作为功能模块模板;每次有用户调用这个模块则将可变部分替换成用户需要的信息即可。示例:功能:发邮件描述:给同组的用户发送邮件,邮件内容相同(不可变)发送的用户不同(可变)
  2. 精心设计一个深克隆对象本程序缓存,作为功能模块的初始对象,例如:“游客模式”每个游客进入系统访问的都是初始对象,基于初始对象发展出多条变化不一的游览路线。只要你想的到设计巧妙,很多功能都能应用object clone。

参考文章:

object clone 的用法、原理和用途

细说Java的深拷贝和浅拷贝

4.12 序列化

将java对象转换成IO流(字节序列),用以存在磁盘上或在网络中传输。

1. 实现过程

Java类实现Serializable接口,然后用java.io.ObjectOutputStream的writeObject(Object obj)方法对参数指定的obj对象进行序列化;用java.io.ObjectInputStream的readObject()方法进行反序列化。

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
private static void serialize(String path, Object object)
{
    File file = new File(path);
    try {
        FileOutputStream outputStream = new FileOutputStream(file);
        //将对象写到流中去
        ObjectOutputStream oos = new ObjectOutputStream(outputStream);
        oos.writeObject(object);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

private static Object unSerialize(String path){
    File file = new File(path);
    Object object = null;
    try {
        FileInputStream inputStream = new FileInputStream(file);
        ObjectInputStream ois = new ObjectInputStream(inputStream);
        object = ois.readObject();
    } catch (IOException e) {
        e.printStackTrace();
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }
    return object;
}

2. 应用场景

序列化对象是以字节数组保存对象的“状态”,即它的成员变量,不会保存类的静态成员。

其常用的应用场景是在持久化对象、RMI(远程方法调用)、在网络当中传递对象时,都要用到对象序列化。

3. 序列化ID的作用

序列化ID决定着能否成功地进行反序列化,java反序列化机制通过在运行时判断类的serialVersionUID来验证版本一致性。在进行反序列化时,JVM会把传入的字节流中的serialVersionUID和实体类中的serialVersionUID进行比较,如果相同则认为一致,可以反序列化,否则报序列化版本不一致的异常。

关于序列化ID的产生:

当实体类中没有显式定义一个名为“private static final long serialVersionUID”的变量时,JAVA序列化机制会在编译时根据类信息自动生成一个serialVersionUID,这种情况下只有同一次编译生成的类才会有相同的serialVersionUID,才能反序列化。一旦重新编译,就不能再反序列化了。

在这种机制下,如果以后扩展了实体类的字段或方法等,对类进行了重新编译,那以前序列化保存的那个持久化文件就不能再被反序列化了。解决方法是,用idea在类中显式定义一个“private static final long serialVersionUID”的变量。

5.