面试之Java基础


Java 基础

Java基础部分,包括语法基础,泛型,注解,异常,反射和其它(如SPI机制等)。

语法基础

面向对象特性

封装

把抽象出的数据[属性]和对数据的操作[方法]封装在一起,数据被保护在内部,程序的其他部分只能通过被授权的操作[方法],才能对数据进行操作

  1. 将属性进行私有化 private
  2. 提供一个公共的 set 方法,用户对数据进行赋值
  3. 提供一个公共的 get 方法,用于获取属性的值

优点:

  • 减少耦合: 可以独立地开发、测试、优化、使用、理解和修改
  • 减轻维护的负担: 可以更容易被程序员理解,并且在调试的时候可以不影响其他模块
  • 有效地调节性能: 可以通过剖析确定哪些模块影响了系统的性能
  • 提高软件的可重用性
  • 降低了构建大型系统的风险: 即使整个系统不可用,但是这些独立的模块却有可能是可用的

继承

继承实现了 IS-A 关系,可以解决代码复用,让我们的编程更加靠近人类思维。当多个类存在相同的属性(变量)和方法时,可以从这些类中抽象出父类,在父类中定义这些相同的属性和方法,所有的子类不需要重新定义这些属性和方法,只需要通过 extends 来声明继承父类即可。

注意:

  1. 继承应该遵循里氏替换原则,子类对象必须能够替换掉所有父类对象。
  2. 子类必须调用父类的构造器, 完成父类的初始化
  3. 当创建子类对象时,不管使用子类的哪个构造器,默认情况下总会去调用父类的无参构造器,如果父类没有提供无参构造器,则必须在子类的构造器中用 super 去指定使用父类的哪个构造器完成对父类的初始化工作,否则,编译不会通过
  4. super() 和 this() 都只能放在构造器第一行,因此这两个方法不能共存在一个构造器

多态

多态是建立在封装和继承基础之上的,分为编译时多态和运行时多态。

  • 编译时多态:方法的重载和重写
  • 运行时多态:程序中定义的对象引用所指向的具体类型在运行期间才确定

运行时多态有三个条件:

  • 继承
  • 覆盖(重写)
  • 向上转型:父类类型 引用名 = new 子类类型();
    • 编译看左,运行看右
    • 可以调用父类中的所有成员
    • 不能调用子类中的特有成员
    • 最终运行效果看子类的具体实现

a = a + b 与 a += b

  • += 隐式的将加操作的结果类型强制转换为持有结果的类型。

  • 如果两个整型相加,如 byte、short 或者 int,首先会将它们提升到 int 类型,然后在执行加法操作。

    1
    2
    3
    4
    byte a = 127;
    byte b = 127;
    b = a + b; // error : cannot convert from int to byte
    b += a; // ok

    (因为 a+b 操作会将 a、b 提升为 int 类型,所以将 int 类型赋值给 byte 就会编译出错)

3*0.1 == 0.3 的返回值

false,因为有些浮点数不能完全精确的表示出来。

Switch 表达式

switch(表达式)中表达式的返回值必须是:byte, short, int, char, enum, String

从 Java 7 开始,我们可以在 switch case 中使用字符串,但这仅仅是一个语法糖。内部实现在 switch 中使用字符串的 hash code。

equals() 和 hashCode()

  • 为什么在重写 equals 方法的时候需要重写 hashCode 方法?

根据 Object 规范,规范约定:

  1. 如果两个对象通过 equals 方法比较是相等的,那么它们的 hashCode 方法结果值也是相等的。
  2. 如果两个对象通过 equals 方法比较是不相等的,那么不要求它们的 hashCode 方法结果值是相等的。
  3. 当在一个应用程序执行过程中, 如果 equals 方法比较中没有修改任何信息,那么在同一个对象上重复调用 hashCode 方法时,它必须始终返回相同的值。但如果从一个应用程序到了另一个应用程序,两个应用程序汇中调用 hashCode 方法的返回值可以是不一致的。

Object 类中的默认的 equals 和 hashCode 方法:

  1. equals:比较的是对象的内存地址是否相同(相当于==操作符);
  2. hashCode:hashCode方法的返回值符合上述规范。

因此,当只重写 equals 方法,不重写 hashCode 时,违反规定:equals 相等的对象必须具有相等的哈希码(因为hashCode的返回值还是按照Object类的规范:同一对象的hashCode值相同)。

如果不这样做,则类违反了hashCode的通用约定,对于HashSet, HashMap, HashTable等基于hash值的类就会出现问题。以 set 集合为例,它用 equals 方法判断两个对象是否相等,如果两个对象相等但是 hashCode 不同,这时候 set 是会添加成功的,与 set 规则冲突

  • 有没有可能两个不相等的对象有相同的 hashcode?

有可能,两个不相等的对象可能会有相同的 hashcode 值,这就是为什么在 hashmap 中会有冲突。相等 hashcode 值的规定只是说如果两个对象相等,必须有相同的hashcode 值,但是没有关于不相等对象的任何规定。

  • 两个相同的对象会有不同的 hash code 吗?

不能,根据 hash code 的规定,这是不可能的。

final、finalize 和 finally

  • final 是一个修饰符,可以修饰变量、方法和类。如果 final 修饰变量,意味着该变量的值在初始化后不能被改变。
  • Java 技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的,但是什么时候调用 finalize 没有保证。
  • finally 是一个关键字,与 try 和 catch 一起用于异常的处理。finally 块一定会被执行,无论在 try 块中是否有发生异常。

String、StringBuffer 与 StringBuilder

  • 可变和适用范围。String 对象是不可变的,而 StringBuffer 和 StringBuilder 是可变字符序列。每次对 String 的操作相当于生成一个新的 String 对象,而对 StringBuffer 和 StringBuilder 的操作是对对象本身的操作,而不会生成新的对象,所以对于频繁改变内容的字符串避免使用 String,因为频繁的生成对象将会对系统性能产生影响。
  • 线程安全。String 由于有 final 修饰,是不可变的,也就可以理解为常量,线程安全。StringBuilder 和 StringBuffer 的区别在于 StringBuilder 不保证同步,StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,线程安全。StringBuilder 并没有对方法进行加同步锁,非线程安全

接口与抽象类

  • 一个子类只能继承一个抽象类,但能实现多个接口
  • 抽象类可以有构造方法,接口没有构造方法
  • 抽象类可以有普通成员变量,接口没有普通成员变量
  • 抽象类和接口都可有静态成员变量,抽象类中静态成员变量访问类型任意,接口只能 public static final (默认)
  • 抽象类可以没有抽象方法,抽象类可以有普通方法;接口在 JDK8 之前都是抽象方法,在JDK8可以有default方法,在JDK9中允许有私有普通方法
  • 抽象类可以有静态方法;接口在JDK8之前不能有静态方法,在JDK8中可以有静态方法,且只能被接口类直接调用(不能被实现类的对象调用)
  • 抽象类中的方法可以是public、protected; 接口方法在JDK8之前只有public abstract,在JDK8可以有default方法,在JDK9中允许有private方法
抽象类 接口
构造方法 可以有 不可以有
普通成员变量 可以有 不可以有
静态成员变量 访问类型任意 只能 public static final
方法 可以没有抽象方法,可以有普通方法 JDK8 之前:抽象方法,JDK8可以有default方法,JDK9中允许有私有普通方法
静态方法 可以有 JDK8之前不能有静态方法,JDK8中可以有静态方法,只能被接口类直接调用

this() & super()

  • 调用 super() 必须写在子类构造方法的第一行,否则编译不通过
  • super 从子类调用父类构造,this 在同一类中调用其他构造均需要放在第一行
  • 尽管可以用 this 调用一个构造器,却不能调用2个
  • this 和 super 不能出现在同一个构造器中,否则编译不通过
  • this()、super()都指的对象,不可以在 static 环境中使用
  • 本质 this 指向本对象的指针。super是一个关键字

Java 移位运算符?

java中有三种移位运算符

  • << :左移运算符,x << 1,相当于 x 乘以 2(不溢出的情况下),低位补0
  • >> :带符号右移,x >> 1,相当于 x 除以 2,正数高位补0,负数高位补1
  • >>> :无符号右移,忽略符号位,空位都以0补齐

泛型

为什么需要泛型

  1. 适用于多种数据类型执行相同的代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    private static int add(int a, int b) {
    System.out.println(a + "+" + b + "=" + (a + b));
    return a + b;
    }

    private static float add(float a, float b) {
    System.out.println(a + "+" + b + "=" + (a + b));
    return a + b;
    }

    private static double add(double a, double b) {
    System.out.println(a + "+" + b + "=" + (a + b));
    return a + b;
    }

    如果没有泛型,要实现不同类型的加法,每种类型都需要重载一个add方法;通过泛型,我们可以复用为一个方法:

    1
    2
    3
    4
    private static <T extends Number> double add(T a, T b) {
    System.out.println(a + "+" + b + "=" + (a.doubleValue() + b.doubleValue()));
    return a.doubleValue() + b.doubleValue();
    }
  2. 泛型中的类型在使用时指定,不需要强制类型转换类型安全,编译器会检查类型

    1
    2
    3
    4
    List list = new ArrayList();
    list.add("xxString");
    list.add(100d);
    list.add(new Person());

    在使用上述list中,list中的元素都是Object类型(无法约束其中的类型),所以在取出集合元素时需要人为的强制类型转化到具体的目标类型,且很容易出现java.lang.ClassCastException异常。

    引入泛型,它将提供类型的约束,提供编译前的检查:

    1
    2
    3
    List<String> list = new ArrayList<String>();

    // list中只能放String, 不能放其它类型的元素

泛型类如何定义使用

  • 简单泛型类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Point<T>{         // 此处可以随便写标识符号,T是type的简称  
    private T var ; // var的类型由T指定,即:由外部指定
    public T getVar(){ // 返回值的类型由外部决定
    return var ;
    }
    public void setVar(T var){ // 设置的类型也由外部决定
    this.var = var ;
    }
    }
    public class GenericsDemo06{
    public static void main(String args[]){
    Point<String> p = new Point<String>() ; // 里面的var类型为String类型
    p.setVar("it") ; // 设置字符串
    System.out.println(p.getVar().length()) ; // 取得字符串的长度
    }
    }
  • 多元泛型类

    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
    class Notepad<K,V>{       // 此处指定了两个泛型类型  
    private K key ; // 此变量的类型由外部决定
    private V value ; // 此变量的类型由外部决定
    public K getKey(){
    return this.key ;
    }
    public V getValue(){
    return this.value ;
    }
    public void setKey(K key){
    this.key = key ;
    }
    public void setValue(V value){
    this.value = value ;
    }
    }
    public class GenericsDemo09{
    public static void main(String args[]){
    Notepad<String,Integer> t = null ; // 定义两个泛型类型的对象
    t = new Notepad<String,Integer>() ; // 里面的key为String,value为Integer
    t.setKey("汤姆") ; // 设置第一个内容
    t.setValue(20) ; // 设置第二个内容
    System.out.print("姓名;" + t.getKey()) ; // 取得信息
    System.out.print(",年龄;" + t.getValue()) ; // 取得信息

    }
    }

泛型接口如何定义使用

  • 简单泛型接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    interface Info<T>{        // 在接口上定义泛型  
    public T getVar() ; // 定义抽象方法,抽象方法的返回值就是泛型类型
    }
    class InfoImpl<T> implements Info<T>{ // 定义泛型接口的子类
    private T var ; // 定义属性
    public InfoImpl(T var){ // 通过构造方法设置属性内容
    this.setVar(var) ;
    }
    public void setVar(T var){
    this.var = var ;
    }
    public T getVar(){
    return this.var ;
    }
    }
    public class GenericsDemo24{
    public static void main(String arsg[]){
    Info<String> i = null; // 声明接口对象
    i = new InfoImpl<String>("汤姆") ; // 通过子类实例化对象
    System.out.println("内容:" + i.getVar()) ;
    }
    }

泛型方法如何定义使用

泛型方法,是在调用方法的时候指明泛型的具体类型。

  • 定义泛型方法语法格式

    img

    Class<T>的作用就是指明泛型的具体类型,而Class<T>类型的变量c,可以用来创建泛型类的对象。

    为什么要用变量 c 来创建对象呢?既然是泛型方法,就代表着我们不知道具体的类型是什么,也不知道构造方法如何,因此没有办法去new一个对象,但可以利用变量 c 的 newInstance 方法去创建对象,也就是利用反射创建对象

  • 调用泛型方法语法格式

    img

    forName()方法中的参数是何种类型,返回的Class<T>就是何种类型

    为什么要使用泛型方法呢?因为泛型类要在实例化的时候就指明类型,如果想换一种类型,不得不重新new一次,可能不够灵活;而泛型方法可以在调用的时候指明类型,更加灵活。

泛型的上限和下限

  • 泛型不具备继承性
  • < ? >:支持任意泛型类型
  • < ? extends A >:支持A类以及A类的子类,规定了泛型的上限
  • < ? super A >:支持A类以及A类的父类,不限于直接父类,规定了泛型的下限

伪泛型

泛型中类型擦除。Java 泛型这个特性是从 JDK 1.5 才开始加入的,因此为了兼容之前的版本,Java 泛型的实现采取了“伪泛型”的策略,即 Java 在语法上支持泛型,但是在编译阶段会进行所谓的“类型擦除”,将所有的泛型表示(尖括号中的内容)都替换为具体的类型(其对应的原生态类型),就像完全没有泛型一样。

注解

注解的作用

注解是 JDK1.5 版本开始引入的一个特性,用于对代码进行说明,可以对包、类、接口、字段、方法参数、局部变量等进行注解。它主要的作用有以下四方面:

  • 生成文档,通过代码里标识的元数据生成 javadoc文档。
  • 编译检查,通过代码里标识的元数据让编译器在编译期间进行检查验证。
  • 编译时动态处理,编译时通过代码里标识的元数据动态处理,例如动态生成代码。
  • 运行时动态处理,运行时通过代码里标识的元数据动态处理,例如使用反射注入实例。

注解的常见分类?

  • Java 自带的标准注解,包括@Override@Deprecated@SuppressWarnings,分别用于标明重写某个方法、标明某个类或方法过时、标明要忽略的警告,用这些注解标明后编译器就会进行检查。

  • 元注解,元注解是用于定义注解的注解,包括@Retention@Target@Inherited@Documented

    • @Retention用于标明注解被保留的阶段
    • @Target用于标明注解使用的范围
    • @Inherited用于标明注解可继承
    • @Documented用于标明是否生成javadoc文档
  • 自定义注解,可以根据自己的需求定义注解,并可用元注解对自定义注解进行注解。

异常

Java异常类层次结构?

  • Throwable是 Java 语言中所有错误与异常的超类。

    • Error 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误,如:JVM系统内部错误、资源耗尽等情况。
    • Exception 程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常

    img

  • 运行时异常

都是 RuntimeException 类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)、ClassCastException(类型转换异常)、NumberFormatException(数字格式不正确异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。

运行时异常的特点是 Java 编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用 try-catch 语句捕获它,也没有用 throws 子句声明抛出它,也会编译通过。

  • 编译异常

是RuntimeException以外的异常,类型上都属于 Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如IOException、SQLException、ClassNotFoundException、FileNotFoundException等以及用户自定义的 Exception 异常,一般情况下不自定义检查异常。

可查的异常和不可查的异常

  • 可查异常(编译器要求必须处置的异常):

正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。

除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。

  • 不可查异常(编译器不要求强制处置的异常)

包括运行时异常(RuntimeException与其子类)和错误(Error)。

throw 和 throws

  • 异常的申明(throws)

在 Java 中,当前执行的语句必属于某个方法,Java 解释器调用 main 方法执行开始执行程序。若方法中存在检查异常,如果不对其捕获,那必须在方法头中显式声明该异常,以便于告知方法调用者此方法有异常,需要进行处理。 在方法中声明一个异常,方法头中使用关键字 throws,后面接上要声明的异常。若声明多个异常,则使用逗号分割。

  • 异常的抛出(throw)

如果代码可能会引发某种错误,可以创建一个合适的异常类实例并抛出它,这就是抛出异常。

Java 7 的 try-with-resource?

如果你的资源实现了 AutoCloseable 接口,你可以使用这个语法。大多数的 Java 标准资源都继承了这个接口。当你在 try 子句中打开资源,资源会在 try 代码块执行后或异常处理后自动关闭。

1
2
3
4
5
6
7
8
9
10
public void automaticallyCloseResource() {
File file = new File("./tmp.txt");
try (FileInputStream inputStream = new FileInputStream(file);) {
// use the inputStream to read a file
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
}
}

异常的底层

异常表:包含了一个或多个异常处理者(Exception Handler)的信息

  • from 可能发生异常的起始点
  • to 可能发生异常的结束点
  • target 上述from和to之前发生异常后的异常处理者的位置
  • type 异常处理者处理的异常的类信息

反射

什么是反射

Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 java 语言的反射机制。

img

反射的使用

在 Java 中,Class 类与 java.lang.reflect 类库一起对反射技术进行了全力的支持。在反射包中,我们常用的类主要有 Constructor 类表示的是 Class 对象所表示的类的构造方法,利用它可以在运行时动态创建对象、Field 表示 Class 对象所表示的类的成员变量,通过它可以在运行时动态修改成员变量的属性值(包含 private )、Method 表示 Class 对象所表示的类的成员方法,通过它可以动态调用对象的方法(包含 private )

  • Class类对象的获取

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Test
    public void classTest() throws Exception {
    // 获取Class对象的三种方式
    logger.info("根据类名: \t" + User.class);
    logger.info("根据对象: \t" + new User().getClass());
    logger.info("根据全限定类名:\t" + Class.forName("com.test.User"));
    // 常用的方法
    logger.info("获取全限定类名:\t" + userClass.getName());
    logger.info("获取类名:\t" + userClass.getSimpleName());
    logger.info("实例化:\t" + userClass.newInstance());
    }

getName、getCanonicalName 与 getSimpleName

  • getSimpleName:只获取类名
  • getName:类的全限定名,jvm 中 Class 的表示,可以用于动态加载 Class 对象,例如 Class.forName。
  • getCanonicalName:返回更容易理解的表示,主要用于输出(toString)或 log 打印,大多数情况下和 getName 一样,但是在内部类、数组等类型的表示形式就不同了。

SPI 机制

什么是 SPI 机制

SPI(Service Provider Interface),是 JDK 内置的一种服务提供发现机制,是一种动态替换的发现机制,可以用来启用框架扩展和替换组件,主要是被框架的开发人员使用,比如 java.sql.Driver 接口,其他不同厂商可以针对同一接口做出不同的实现,MySQL 和 PostgreSQL 都有不同的实现提供给用户,而 Java 的 SPI 机制可以为某个接口寻找服务实现。Java 中 SPI 机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是 解耦

SPI整体机制图如下:

img

当服务的提供者提供了一种接口的实现之后,需要在 classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,这个文件里的内容就是这个接口的具体的实现类。当其他的程序需要这个服务的时候,就可以通过查找这个 jar 包(一般都是以jar包做依赖)的 META-INF/services/ 中的配置文件,配置文件中有接口的具体实现类名,可以根据这个类名进行加载实例化,就可以使用该服务了。JDK中查找服务的实现的工具类是:java.util.ServiceLoader

优点

  • 使用 Java SPI 机制的优势是实现解耦,使得第三方服务模块的装配控制的逻辑与调用者的业务代码分离,而不是耦合在一起。应用程序可以根据实际业务情况启用框架扩展或替换框架组件。

缺点

  • 虽然 ServiceLoader 也算是使用的延迟加载,但是基本只能通过遍历全部获取,也就是接口的实现类全部加载并实例化一遍。如果你并不想用某些实现类,它也被加载并实例化了,这就造成了浪费。获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。
  • 多个并发多线程使用 ServiceLoader 类的实例是不安全的。

SPI机制的应用

  • SPI 机制 - JDBC DriverManager

在 JDBC4.0 之前,我们开发有连接数据库的时候,通常会用 Class.forName(“com.mysql.jdbc.Driver”) 这句先加载数据库相关的驱动,然后再进行获取连接等的操作。而 JDBC4.0 之后不需要用 Class.forName(“com.mysql.jdbc.Driver”) 来加载驱动,直接获取连接就可以了,现在这种方式就是使用了 Java 的 SPI 扩展机制来实现

  • JDBC 接口定义

首先在 java 中定义了接口 java.sql.Driver,并没有具体的实现,具体的实现都是由不同厂商来提供的。

  • mysql 实现

在 mysql 的 jar 包mysql-connector-java-6.0.6.jar中,可以找到META-INF/services目录,该目录下会有一个名字为java.sql.Driver的文件,文件内容是com.mysql.cj.jdbc.Driver,这里面的内容就是针对 Java 中定义的接口的实现。

  • postgresql 实现

同样在 postgresql 的 jar 包postgresql-42.0.0.jar中,也可以找到同样的配置文件,文件内容是org.postgresql.Driver,这是 postgresql 对 Java 的java.sql.Driver的实现。

  • 使用方法

上面说了,现在使用 SPI 扩展来加载具体的驱动,我们在 Java 中写连接数据库的代码的时候,不需要再使用Class.forName("com.mysql.jdbc.Driver")来加载驱动了,而是直接使用如下代码:

1
2
3
String url = "jdbc:xxxx://xxxx:xxxx/xxxx";
Connection conn = DriverManager.getConnection(url,username,password);
.....

SPI 机制的简单示例

我们现在需要使用一个内容搜索接口,搜索的实现可能是基于文件系统的搜索,也可能是基于数据库的搜索。

  • 先定义好接口
1
2
3
public interface Search {
public List<String> searchDoc(String keyword);
}
  • 文件搜索实现
1
2
3
4
5
6
7
public class FileSearch implements Search{
@Override
public List<String> searchDoc(String keyword) {
System.out.println("文件搜索 "+keyword);
return null;
}
}
  • 数据库搜索实现
1
2
3
4
5
6
7
public class DatabaseSearch implements Search{
@Override
public List<String> searchDoc(String keyword) {
System.out.println("数据搜索 "+keyword);
return null;
}
}
  • resources 接下来可以在resources下新建META-INF/services/目录,然后新建接口全限定名的文件:com.cainiao.ys.spi.learn.Search,里面加上我们需要用到的实现类
1
com.cainiao.ys.spi.learn.FileSearch
  • 测试方法
1
2
3
4
5
6
7
8
9
10
public class TestCase {
public static void main(String[] args) {
ServiceLoader<Search> s = ServiceLoader.load(Search.class);
Iterator<Search> iterator = s.iterator();
while (iterator.hasNext()) {
Search search = iterator.next();
search.searchDoc("hello world");
}
}
}

可以看到输出结果:文件搜索 hello world

如果在com.cainiao.ys.spi.learn.Search文件里写上两个实现类,那最后的输出结果就是两行了。

这就是因为ServiceLoader.load(Search.class)在加载某接口时,会去META-INF/services下找接口的全限定名文件,再根据里面的内容加载相应的实现类。

这就是 spi 的思想,接口的实现由 provider 实现,provider 只用在提交的 jar 包里的META-INF/services下根据平台定义的接口新建文件,并添加进相应的实现类内容就好。

Java 集合

集合主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。

Collection

集合有哪些类

  • Set
    • TreeSet 基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN),线程不安全
    • HashSet 基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的,线程不安全
    • LinkedHashSet 具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序,有序,线程不安全
  • List 有序
    • ArrayList 基于动态数组实现,支持随机访问,线程不安全
    • Vector 和 ArrayList 类似,但它是线程安全的。
    • LinkedList 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列,线程不安全
  • Queue
    • LinkedList 可以用它来实现双向队列。
    • PriorityQueue 基于堆结构实现,可以用它来实现优先队列。

ArrayList 底层

ArrayList 实现了 List 接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入null元素,底层通过数组实现。除该类未实现同步外,其余跟Vector大致相同。每个ArrayList都有一个容量(capacity),表示底层数组的实际大小,容器内存储元素的个数不能多于当前容量。当向容器中添加元素时,如果容量不足,容器会自动增大底层数组的大小。前面已经提过,Java泛型只是编译器提供的语法糖,所以这里的数组是一个 Object 数组,以便能够容纳任何类型的对象。

ArrayList_base

ArrayList 自动扩容

每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。数组扩容通过 ensureCapacity(int minCapacity) 方法来实现。在实际添加大量元素前,我也可以使用 ensureCapacity 来手动增加 ArrayList 实例的容量,以减少递增式再分配的数量。

创建 ArrayList 对象时,如果使用的是无参构造器,则初始化容量为 0,第一次扩容为 10,之后再扩容为 1.5 倍;数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍。这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用 ensureCapacity 方法来手动增加ArrayList 实例的容量。

ArrayList_add

ArrayList 的 Fail-Fast机制

定义:在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出 Concurrent Modification Exception。

原理:ArrayList也采用了快速失败的机制,通过记录 modCount 参数来实现。在面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险。迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出异常,终止遍历。

场景:java.util 包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)

List,Set,Map 三者的区别

  • List (对付顺序的好帮⼿):存储的元素是有序的、可重复的。
  • Set (注重独⼀⽆⼆的性质):存储的元素是⽆序的、不可重复的。
  • Map (⽤ Key 来搜索的专家):使⽤键值对(kye-value)存储,类似于数学上的函数 y=f(x),“x”代表 key,”y”代表 value,Key 是⽆序的、不可重复的,value 是⽆序的、可重复的,每个键最多映射到⼀个值。

Arraylist 与 LinkedList 区别

  • 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
  • 底层数据结构: Arraylist 底层使⽤的是 Object 数组; LinkedList 底层使⽤的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下⾯有介绍到!)
  • 插⼊和删除是否受元素位置的影响:
    • ArrayList 采⽤数组存储,所以插⼊和删除元素的时间复杂度受元素位置的影响。 ⽐如:执⾏ add(E e) ⽅法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插⼊和删除元素的话( add(int index, E element) )时间复杂度就为 O(n-i)。因为在进⾏上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执⾏向后位/向前移⼀位的操作。
    • LinkedList 采⽤链表存储,所以对于 add(E e) ⽅法的插⼊,删除元素时间复杂度不受元素位置的影响,近似 **O(1)**,如果是要在指定位置 i 插⼊和删除元素的话( (add(int index, Eelement) ) 时间复杂度近似为 o(n)) 因为需要先移动到指定位置再插⼊。
  • 是否⽀持快速随机访问: LinkedList 不⽀持⾼效的随机元素访问,⽽ ArrayList ⽀持。快速随机访问就是通过元素的序号快速获取元素对象(对应于 get(int index) ⽅法)。
  • 内存空间占⽤: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留⼀定的容量空间,⽽ LinkedList 的空间花费则体现在它的每⼀个元素都需要消耗⽐ ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

补充内容:双向链表和双向循环链表

双向链表: 包含两个指针,⼀个 prev 指向前⼀个节点,⼀个 next 指向后⼀个节点。

image-20220906211209097

双向循环链表: 最后⼀个节点的 next 指向 head,⽽ head 的 prev 指向最后⼀个节点,构成⼀个环。

image-20220906211247514

ArrayList 与 Vector 的区别

  • ArrayList 是 List 的主要实现类,底层使⽤ Object[ ] 存储,适⽤于频繁的查找⼯作,线程不安全 ;
  • Vector 是 List 的古⽼实现类,底层使⽤ Object[ ] 存储,线程安全的。

Map

Map 有哪些类

  • TreeMap 基于红黑树实现,线程不安全,有序
  • HashMap 1.7 基于哈希表实现,1.8 基于数组+链表+红黑树线程不安全,无序
  • HashTable 和 HashMap 类似,但它是线程安全,无序的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高(1.7 ConcurrentHashMap 引入了分段锁, 1.8 引入了红黑树)。
  • LinkedHashMap 使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序,线程不安全,有序

JDK7 HashMap

哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java7 HashMap 采用的是冲突链表方式

HashMap_base

从上图容易看出,如果选择合适的哈希函数,put()get()方法可以在常数时间内完成。但在对HashMap进行迭代时,需要遍历整个table以及后面跟的冲突链表。因此对于迭代比较频繁的场景,不宜将HashMap的初始大小设的过大。

有两个参数可以影响HashMap的性能: 初始容量(inital capacity)和负载系数(load factor)。初始容量指定了初始table的大小,负载系数用来指定自动扩容的临界值。当entry的数量超过capacity*load_factor时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。

JDK8 HashMap

根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 **O(n)**。

为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 **O(logN)**。

img

HashSet

HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成合适的HashMap方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//HashSet是对HashMap的简单包装
public class HashSet<E>
{
......
private transient HashMap<E,Object> map;//HashSet里面有一个HashMap
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
......
public boolean add(E e) {//简单的方法转换
return map.put(e, PRESENT)==null;
}
......
}

WeakHashMap

我们都知道 Java 中内存是通过 GC 自动管理的,GC 会在程序运行过程中自动判断哪些对象是可以被回收的,并在合适的时机进行内存释放。GC 判断某个对象是否可被回收的依据是,是否有有效的引用指向该对象。如果没有有效引用指向该对象(基本意味着不存在访问该对象的方式),那么该对象就是可回收的。这里的有效引用并不包括弱引用。也就是说,虽然弱引用可以用来访问对象,但进行垃圾回收时弱引用并不会被考虑在内,仅有弱引用指向的对象仍然会被 GC 回收

WeakHashMap 内部是通过弱引用来管理 entry 的,弱引用的特性对应到 WeakHashMap 上意味着什么呢?

WeakHashMap 里的entry可能会被GC自动删除,即使程序员没有调用remove()或者clear()方法。

WeakHashMap 的这个特点特别适用于需要缓存的场景。在缓存场景下,由于内存是有限的,不能缓存所有对象;对象缓存命中可以提高系统效率,但缓存MISS也不会造成错误,因为可以通过计算重新得到。

HashMap 和 Hashtable 的区别

  • 线程是否安全: HashMap 是⾮线程安全的,HashTable 是线程安全的,因为 HashTable 内部的⽅法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使⽤ ConcurrentHashMap 吧!);

  • 效率: 因为线程安全的问题, HashMap 要⽐ HashTable 效率⾼⼀点。另外, HashTable基本被淘汰,不要在代码中使⽤它;

  • Null key Null value 的⽀持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有⼀个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException 。

  • 初始容量大小和每次扩充容量大小的不同 :

  • 创建时如果不指定容量初始值, Hashtable默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。 HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。

  • 创建时如果给定了容量初始值,那么 Hashtable 会直接使⽤你给定的⼤⼩,⽽ HashMap 会将其扩充为 2 的幂次方大小( HashMap 中的 tableSizeFor() ⽅法保证,下⾯给出了源代码)。也就是说 HashMap 总是使⽤ 2 的幂作为哈希表的⼤⼩,后⾯会介绍到为什么是 2 的幂次⽅。

  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红⿊树前会判断,如果当前数组的⻓度⼩于 64,那么会选择先进⾏数组扩容,⽽不是转换为红⿊树)时,将链表转化为红⿊树,以减少搜索时间。Hashtable 没有这样的机制。

HashMap 和 HashSet区别

HashSet 底层就是基于 HashMap 实现的。( HashSet 的源码⾮常⾮常少,因为除了 clone() 、 writeObject() 、 readObject() 是 HashSet⾃⼰不得不实现之外,其他⽅法都是直接调⽤ HashMap 中的⽅法。

HashMap HashSet
实现了 Map 接⼝ 实现 Set 接⼝
存储键值对 仅存储对象
调⽤ put() 向 map 中添加元素 调⽤ add() ⽅法向 Set 中添加元素
HashMap 使⽤键(Key)计算 hashcode HashSet 使⽤成员对象来计算 hashcode 值,对于两个对象来说hashcode 可能相同,所以 equals() ⽅法⽤来判断对象的相等性

HashSet如何检查重复

当你把对象加⼊ HashSet 时, HashSet 会先计算对象的 hashcode 值来判断对象加⼊的位置,同时也会与其他加⼊的对象的 hashcode 值作比较,如果没有相符的 hashcode , HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调⽤ equals() ⽅法来检查hashcode 相等的对象是否真的相同。如果两者相同, HashSet 就不会让加⼊操作成功。

hashCode() equals() 的相关规定:

  1. 如果两个对象相等,则 hashcode ⼀定也是相同的

  2. 两个对象相等,对两个 equals() ⽅法返回 true

  3. 两个对象有相同的 hashcode 值,它们也不⼀定是相等的

  4. 综上,equals() ⽅法被覆盖过,则 hashCode() ⽅法也必须被覆盖

  5. hashCode() 的默认⾏为是对堆上的对象产⽣独特值。如果没有重写 hashCode() ,则该 class 的两个对象⽆论如何都不会相等(即使这两个对象指向相同的数据)。

**== **与 equals 的区别

  • 对于基本类型来说,== 比较的是值是否相等;
  • 对于引⽤类型来说,== 比较的是两个引⽤是否指向同⼀个对象地址(两者在内存中存放的地址(堆内存地址)是否指向同⼀个地⽅);
  • 对于引⽤类型(包括包装类型)来说,equals 如果没有被重写,对比它们的地址是否相等;如果 equals() ⽅法被重写(例如 String),则比较的是地址⾥的内容。

HashMap 的长度为什么是 2 的幂次方

为了能让 HashMap 存取⾼效,尽量减少碰撞,也就是要尽量把数据分配均匀。我们上⾯也讲到了过了,Hash 值的范围值-2147483648到2147483647,前后加起来⼤概40亿的映射空间,只要哈希函数映射得比较均匀松散,⼀般应⽤是很难出现碰撞的。但问题是⼀个40亿⻓度的数组,内存是放不下的。所以这个散列值是不能直接拿来⽤的。⽤之前还要先做对数组的⻓度取模运算,得到的余数才能⽤来要存放的位置也就是对应的数组下标。这个数组下标的计算⽅法是“ (n - 1) &hash ”。(n代表数组⻓度)。这也就解释了 HashMap 的⻓度为什么是2的幂次⽅。

HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash % length,计算机中直接求余效率不如位移运算,源码中做了优化 hash & (length - 1 )hash % length == hash & ( length - 1 ) 的前提是 length 是 2 的 n 次方; 为什么这样能均匀分布减少碰撞呢?2 的 n次方实际就是 1 后面 n 个 0,2的n次方-1 实际就是n个1;

HashMap 多线程操作导致死循环问题

主要原因在于并发下的 Rehash 会造成元素之间会形成⼀个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使⽤ HashMap,因为多线程下使⽤ HashMap 还是会存在其他问题⽐如数据丢失。并发环境下推荐使⽤ ConcurrentHashMap

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的⽅式上不同

  • 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采⽤ 分段的数组+链表 实现,JDK1.8采⽤的数据结构跟 HashMap1.8 的结构⼀样,数组+链表/红⿊⼆叉树。 Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采⽤ 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突⽽存在的;
  • 实现线程安全的⽅式(重要):
    • JDK1.7 的时候, ConcurrentHashMap (分段锁)对整个桶数组进⾏了分割分段( Segment ),每⼀把锁只锁容器其中⼀部分数据,多线程访问容器⾥不同数据段的数据,就不会存在锁竞争,提⾼并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,⽽是直接⽤Node 数组+链表+红黑树的数据结构来实现,并发控制使⽤ synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap ,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
    • Hashtable(同⼀把锁) :使⽤ synchronized 来保证线程安全,效率⾮常低下。当⼀个线程访问同步⽅法时,其他线程也访问同步⽅法,可能会进⼊阻塞或轮询状态,如使⽤ put 添加元素,另⼀个线程不能使⽤ put 添加元素,也不能使⽤ get,竞争会越来越激烈效率越低。
    • JDK1.8 的 ConcurrentHashMap 不在是 Segment 数组 + HashEntry 数组 + 链表,⽽是 Node 数组 + 链表 / 红⿊树。不过,Node 只能⽤于链表的情况,红⿊树的情况需要使⽤ TreeNode 。当冲突链表达到⼀定⻓度时,链表会转换成红⿊树。

image-20220906213600702

Java 集合时间复杂度

List

ArrayList

get() 直接读取下标,复杂度 O(1)

add(E) 直接在队尾添加,复杂度 O(1)

add(index, E) 在第n个元素后插入,n后面的元素需要向后移动,复杂度 O(n)

remove() 删除元素后面的元素需要逐个前移,复杂度 O(n)

LinkedList

addFirst() 添加队列头部,复杂度 O(1)

removeFirst() 删除队列头部,复杂度 O(1)

addLast() 添加队列尾部,复杂度 O(1)

removeLast() 删除队列尾部,复杂度 O(1)

getFirst() 获取队列头部,复杂度 O(1)

getLast() 获取队列尾部,复杂度 O(1)

get() 获取第n个元素,依次遍历,复杂度O(n)

add(E) 添加到队列尾部,复杂度O(1)

add(index, E) 添加到第n个元素后,需要先查找到第n个元素,复杂度O(n)

remove() 删除元素,修改前后元素节点指针,复杂度O(1)

Set

HashSet

add() 复杂度为 O(1)

remove() 复杂度为 O(1)

contains() 复杂度为 O(1)

TreeSet(基于红黑树)

add() 复杂度为 O(log (n))

remove() 复杂度为 O(log (n))

contains() 复杂度为 O(log (n))

map

TreeMap(基于红黑树)

平均时间复杂度 O(log n)

HashMap

正常时间复杂度 O(1)~O(n)

红黑树后 O(log n)

LinkedHashMap

能以时间复杂度 O(1) 查找元素,又能够保证key的有序性

集合的选择

主要根据集合的特点来选⽤,⽐如我们需要根据键值获取到元素值时就选⽤ Map 接口下的集合,需要排序时选择 TreeMap,不需要排序时就选择 HashMap,需要保证线程安全就选⽤ ConcurrentHashMap。

当我们只需要存放元素值时,就选择实现 Collection 接⼝的集合,需要保证元素唯⼀时选择实现 Set 接⼝的集合⽐如 TreeSet 或 HashSet,不需要就选择实现 List 接口的⽐如 ArrayList 或 LinkedList ,然后再根据实现这些接⼝的集合的特点来选⽤。


文章作者: Yang Shiyu
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Yang Shiyu !
  目录