Java面试-菜鸡篇


首先说一点吧,这一部分主要还是参考的JavaGuide,真心觉得这个是个很棒的东西。所以。。嗯。附上链接
JavaGuide

这里只记录一些我个人觉得有必要的东西。。。。

基础知识

入门

Java的特点:

  1. 面向对象(封装、继承、多态)
  2. 平台无关性(一次编译,到处运行)
  3. 支持多线程
  4. 编译与解释并存

JVM,JDK,JRE

JVM就是Java虚拟机,是运行Java字节码(**.**class文件)的虚拟机,Java的平台无关性主要就是靠JVM,JVM对针对不同的系统有特定的实现,目的就是使用相同的字节码,在不同的平台上都可以得出相同的结果。
在这里插入图片描述
在这里插入图片描述

JDK,即Java Development kit,Java开发工具,它拥有jre所拥有的的一切。还有编译器(javac)和工具、能够创建和编译程序。

JRE 就是Java运行环境。包含了JVM,Java类库,以及其他一些基础构件。

Java执行的过程(类加载过程)

在这里插入图片描述
这里主要记录一下类加载过程。
一个Class在虚拟机中的完整生命周期主要如图所示
在这里插入图片描述
在这里插入图片描述

1、加载: 简单来说就是把Class字节码文件通过类加载器加载到内存中。(类加载器相关的知识会在下面提到)

加载一个Class大致需要完成3件事。

  • –通过Class的全限定名获取到Class的二进制字节流。
  • 将class的二进制内容加载到虚拟机的方法区
  • 在内存中生成一个java.lang.CLass对象表示这个Class

2、检验: 主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全问题

要验证一个Class的二进制内容是否合法,主要包括了4个阶段

  • 文件格式验证:确保文件格式符合Class文件格式规范。
  • 元数据验证:确保Class的语义符合Java的Class规范,是否final修饰等等。
  • 字节码验证:保证程序语义的合理性。比如要保证类型转换的合理性。
  • 符号引用验证:发生于符号引用转换为直接引用的时候(解析阶段)

3、准备: 准备阶段主要做的就是jvm会在方法区中为Class分配内存,并且设置static成员变量的初始值。仅仅为static变量分配内存(在方法区中)默认值的话,int就是0,引用类型就是null。

4、解析: 解析阶段,虚拟机会将常量池中的符号引用替换为直接引用,解析主要针对的是类,接口,方法,成员变量等符号引用。在转换成直接引用之后,会触发校验阶段的引用验证,验证转换之后的直接引用能否找到对应的类,方法,成员变量等。

这里的符号引用就是一个字符串,但是这个字符串给出了一些能够唯一标识一个类,一个方法,一个变量的相关信息
直接引用则可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针。而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。
举个例子就是,调用方法F(),这个方法的地址是1234,那么f就是符号引用,1234就是直接引用。

5、初始化: 初始化阶段即开始在内存中构造一个Class对象来表示该类。即,执行类构造器<client>。此方法由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成。虚拟机会保证在执行<client>防范之前,父类的<client>方法已经执行完毕。如果一个类中没有对静态变量的赋值也也没有静态语句块,则编译器可以不为这个类生成<client>方法

在java中,有且只有一下5种情况才需要对类立刻初始化(加载,验证,准备自然是需要再次之前开始的)

  • 使用new关键字实例化对象,访问或者设置一个类的静态字段(被final修饰,编译器优化时已经放入常量池的例外),调用类的方法,都会初始化该静态字段或者静态方法所在的类。
  • 初始化类的时候如果其父类没有被初始化过,则要先触发父类的初始化
  • 使用反射调用的时候,如果类没有被初始化,则要先初始化
  • 虚拟机启动时,用户会先初始化要执行的主类(含有main)
  • jdk 1.7后,如果java.lang.invoke.MethodHandle的实例最后对应的解析结果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法所在类没有初始化,则先初始化。

jvm初始化步骤
1、若这个类没有被加载和连接,则先加载并连接
2、若这个类的父类没有被初始化,则先初始化其父类
3、若类总有初始化语句,则依次执行这些初始化语句

能搞懂下面这两段的基本对类加载过程没啥子问题了……

public class JVMTest {
    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println(Singleton.a);
        System.out.println(Singleton.b);
    }
}

class Singleton{
    public static int a ;
    public static int b=0;
    public static Singleton instance = new Singleton();

    public Singleton(){
        a++;
        b++;
    }

    public static Singleton getInstance(){
        return instance;
    }

}

运行结果:在这里插入图片描述

public class JVMTest {
    public static void main(String[] args) {
        Singleton instance = Singleton.getInstance();
        System.out.println(Singleton.a);
        System.out.println(Singleton.b);
    }
}

class Singleton{
    public static Singleton instance = new Singleton();
    public static int a ;
    public static int b=0;

    public Singleton(){
        a++;
        b++;
    }

    public static Singleton getInstance(){
        return instance;
    }

}

运行结果:
在这里插入图片描述

类加载器以及类加载机制

上面其实讲了类加载的过程。这里单独讲一下类加载器以及其加载机制。

类加载器: 主要分为3种

  • 根类加载器(Bootstrap ClassLoader):主要负责加载存放在JDK\jre\lib目录下的类,也可以通过 -Xbootclasspath参数指定路径。
  • 扩展类加载器(ExtClassLoader):负责加载JDK\jre\lib\ext目录中的类。
  • 应用类加载器(AppClassLoader):主要负责加载用户类路径(ClassPath)所指定的类。
  • 还有一个自定义类加载器。

双亲委派模型
类加载器其实和其他java类一样,除了跟类加载器,其他都是其父类加载器(父子关系,并非继承)
所谓的双亲委派模型就是指每次收到加载请求的时候,先将请求委派给父类加载器完成,即所有的请求都会到Bootstrap ClassLoader中。如果父类加载器无法加载,才会交给子类进行加载。
在这里插入图片描述

语法

一些简单的语法这里就不记录了,没得啥子意思。

泛型

Java泛型是JDK5中开始引入的一个新特性。泛型提供了编译时类型安全监测机制,该机制允许我们在编译时监测到非法的类型。泛型的本质是参数化类型 ,Java的泛型实际上是一种伪泛型,因为Java在编译期间,所有的泛型信息都会被擦除
泛型类型擦除: Java的泛型基本都是在编译器这个层次上完成的。在生成的字节码中是不包含泛型中的类型信息,使用泛型的时候加上类型参数,在编译时期会被去掉,这个过程就叫做类型擦除。
例如:List<Object>List<String>在编译之后都会变成List。JVM只能看到List,而由泛型附加的类信息对JVM是不可见的。

 public static void main(String[] args) {
        ArrayList<String> strList = new ArrayList<>();
        strList.add("asda");
        ArrayList<Integer> intList = new ArrayList<>();
        intList.add(111);

        System.out.println(strList.getClass()==strList.getClass());
    }

这里运行的结果是true,就说明泛型类型String和Integer都被擦除了,只剩下原始类型。

==和equals的区别

**==**的作用,对基本数据类型来说是比较的值,对引用数据类型比较的是内存地址值

其实因为Java只有值传递,所以对于 == 来说,不管比较的是基本数据类型还是引用数据类型,本质上都是比较的值,只是引用数据类型变量存放的值是对象的地址

equals 它的作用也是用来判断两个对象是否相等,但是它不能用于比较基本数据类型的变量。
equals的两种使用方法

  1. 未重写equals方法:这时使用equals方法等价于用 “==” 来比较两个对象
  2. 重写了equals方法:重写equals方法使其来比较两个对象的内容是否相等。

hashCode和equals方法
hashCode()方法其实就是获取一个哈希码(散列码),它实际上就是一个int整数,用于确定该对象在哈希表中的索引位置。hashCode()方法是object类中的方法,即所有的类都含有此方法。object中的hashCode()是一个本地方法,即使用c或者c++来实现将对象的内存地址转换成整数之后返回

这里用HashSet来简述hashCode的作用:当向HashSet中加入值的时候,会先计算对象的hashCode值,来判断加入对象的位置,同时也会与其他已经加入的对象的hashCode值做比较。如果没有相同的hashCode,则HashSet认为对象无重复。若发现了有相同的hashCode,此时就会调用equals方法来检查hashCode相同的对象是否真的相同。如果两者相同,HashSet就不会让其插入,如果不同的话,就会重新散列到别的位置。这样减少了equals的次数,提高了执行速度。

为啥重写equals方法时必须重写hashCode方法?
如果两个对象相等,那么他们的hashCode值也一定相等。如果两个对象的hashCode值相等,它们不一定相等。

基本数据类型

Java中的8种基本数据类型:
6种数字型:byte,short,int,long,float,double
1种字符类型:char
1种布尔型:boolean
这八种类型都有对应的包装类:Byte,Short,Integer,Long,Float,Double,Character,Boolean
在这里插入图片描述

自动装箱和自动拆箱

装箱:将基本类型用它们对应的引用类型包装起来
拆箱:将包装类型转换为基本数据类型

Java基本类型的包装类大部分都实现了常量池技术,即Byte,Short,Integer,Character,Boolean;
前4种包装类默认创建了**数值在[-128,127]的相应类型的缓存数据。
character创建了
数值在[0,127]**范围的缓存数据。如果超出了范围,仍需要去创建信新的对象

应用场景
1、Integer i= 30:在Java编译的时候会直接将代码封装成 Integer i = Integer.valueOf(40),从而使用常量池中的对象
valueOf方法会先判断是否在缓存池中,如果有就直接返回缓存池的内容
2、Integer i = new Integer(30) 这种情况下是直接创建新的对象。
3、int x = i :拆箱,调用了i.**intValue()**方法
在这里插入图片描述
在这里插入图片描述
解释:因为i4 == i5+i6的时候,+这个操作符不适用于Integer对象,首先将i5和i6进行自动拆箱,数值相加。然后integer对象无法与数值进行直接比较,所以i4也要自动拆箱转成int值的40.所以最终的结果是true

switch

switch能够接收的类型:在5之前,switch可以接收byte,short,char,int。从5开始,也可以是enum类型。从7开始,还可以是String

final finally finalize区别

  1. final:可以修饰类,变量,方法。被修饰的类不能被继承,被修饰的变量不能被修改,被修饰的方法不能被重写。
  2. finally:一般用于try-catch中,在处理异常的时候一定要执行的代码
  3. finalize:是一个方法,属于Object,一般由垃圾回收器来调用。当调用System.gc()的时候,垃圾回收器调用finalize()方法回收垃圾。

this与super

this是自身的一个对象,代表此对象本身,可以理解成一个指向当前对象的指针。
this的用法大致有3种
4. 普通的直接引用,相当于当前对象本身
5. 形参与成员名称重名,用this来区分

public Person(String name, int age) {
    this.name = name;
    this.age = age;
}
  1. 引用本类的构造参数
class Person{
    private String name;
    private int age;
    
    public Person() {
    }
 
    public Person(String name) {
        this.name = name;
    }
    public Person(String name, int age) {
        this(name);
        this.age = age;
    }
}

super可以理解成指向自己的父类对象的一个指针。
用法也大致分为3种

  1. 普通的直接引用:可以通过super.xxx来引用父类的成员,与this类似
  2. 子类中的成员变量或者方法和父类中的成员变量或方法重名是,可以用super区分
class Person{
    protected String name;
 
    public Person(String name) {
        this.name = name;
    }
 
}
 
class Student extends Person{
    private String name;
 
    public Student(String name, String name1) {
        super(name);
        this.name = name1;
    }
 
    public void getInfo(){
        System.out.println(this.name);      //Child
        System.out.println(super.name);     //Father
    }
}
  1. 引用父类构造函数:super(参数)调用父类中的某一个构造函数(此方法必须为构造函数中的第一条语句)

static关键字

static关键字主要有4种使用场景:

  1. 修饰成员变量或者方法:被static修饰的成员属于类,被类中所有对象共享,并且可以通过 **类名.**的方式来调用。被static修饰的成员变量属于静态成员变量,存放在Java内存区域的方法区中。
  2. 静态代码块:静态代码块在非静态代码块之前执行(静态代码块->非静态代码块->构造方法),不管该类创建多少对象,静态代码块只执行一次。
  3. 静态内部类(static修饰类的话只能是内部类):静态内部类和非静态内部类最大的区别就是:静态内部类在编译完成之后会隐含地保存着一个引用,该引用指向创建它的外围类,但是静态内部类没有。没有这个引用则意味着:1、它的创建不需要依赖外围类。2、他不能使用任何外围类中的非static成员
  4. 静态导包:import static

static存在的主要意义就是创建独立于具体对象的域变量或者方法,这样即使没有创建对象也可以使用属性和调用方法。

static在该类被第一次加载的时候,就会加载被static修饰的部分。而且只有在类第一次使用时加载并进行初始化。

static变量在类加载的时候就分配了空间,以后创建类对象的时候不会重新分配。

注意事项:

  1. 静态只能访问静态,不能访问非静态
  2. 非静态可以访问非静态,也可以访问静态

异常

Java异常类层次结构图
这个没什么好记录的…

为什么Java中只有值传递

按值调用:表示方法接收的是调用者提供的值
引用调用:表示方法接受的是调用者提供的变量地址。
一个方法可以修改传递引用所对应的变量值,而不能修改传递值调动所对应的变量值
Java中采用的是按值传递,所以方法得到的是所有参数值的一个拷贝,无法修改传递给他的任何参数变量的内容。
举例说明:

public static void main(String[] args) {
        int num1 = 100;
        int num2 = 200;

        swap(num1, num2);

        System.out.println("num1 = "+num1);
        System.out.println("num2 = "+num2);
    }

    public static void swap(int a, int b) {
        int temp = a;
        a= b;
        b = temp;

        System.out.println("a = "+a);
        System.out.println("b = "+b);
    }

输出结果:
在这里插入图片描述
分析:
在这里插入图片描述
在swap方法中,a,b的值进行了交换,但是并不会影响到num1和num2,因为a,b中的值,只是num1,num2的副本,无论怎么修改副本,都不会影响到本身。

实例2

public static void main(String[] args) {
        int[] arr = {1,2,3,4,5};
        System.out.println(arr[0]);
        change(arr);
        System.out.println(arr[0]);
    }

    private static void change(int[] array) {
        array[0] = 10;
    }

结果:
在这里插入图片描述
分析:
在这里插入图片描述
array被初始化arr的拷贝,也就是一个对象的引用,也就是说array和arr都指向了同一个数组对象。因此外部对引用对象的改变会影响到所对应的对象上。

这么看的话,好像Java采用的是引用调用,但是实际上并不是。。看案例3

public static void main(String[] args) {
        Student s1 = new Student("小张", 27);
        Student s2 = new Student("小李", 25);

        swapStu(s1, s2);

        System.out.println(s1.toString());
        System.out.println(s2.toString());
    }

    private static void swapStu(Student x, Student y) {
        Student temp = x;
        x = y;
        y = temp;
        System.out.println(x.toString());
        System.out.println(y.toString());
    }

返回结果:
在这里插入图片描述
解析:
在这里插入图片描述
在这里插入图片描述
方法并没有改变存储在变量s1,s2种的对象引用,swapStu方法的参数x,y是被初始化为两个对象的引用的拷贝,这个方法交换的是拷贝。

案例4
这里更加详细的层面来理解值传递:https://www.zhihu.com/question/31203609
首先理解基本类型和引用类型的不同:

int num = 10;
String str = "hello";

在这里插入图片描述
如图所示,基本类型就是直接保存值,引用类型实际上保存的是对象的地址。

其次,搞清楚 “=” 的作用

num = 20;
str = "java";

在这里插入图片描述
对于基本类型来说,赋值运算符会直接改变变量的值,原来的值会被覆盖
对于引用类型来说,赋值运算符会改变引用中保存的地址,原来的地址被覆盖,但是原来的对象不会被改变
如图所示,“hello”对象没有被改变。只是没有被任何引用所指向。而这种对象就是垃圾,会被垃圾回收器回收(具体的gc机制后续再搞)

调用方法的时候,参数传递基本上就是一个赋值的操作。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

多线程

由于多线程的内容比较多,所以会重新写一篇单独的来记录多线程的相关内容。。。

IO流

这里就简单的记录一些基本的东西,后续单独写一篇记录io的

IO流分类

  • 按照流的方法分,可以分为输入流和输出流
  • 按照惭怍单元来分,可以分为字节流和字符流
  • 按照流的角色划分,可以分为节点流和处理流

Java中io流设计的类特别多,看似杂乱无章,但是这些类都是从以下4个抽象类中派生出来的
InputStream、Reader:所有输入流的基类,前者是字节输入流,后者是字符输入流
OutputSream、Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。
在这里插入图片描述
在这里插入图片描述

一些面试题

short s1 = 1; s1 = s1 + 1;有错吗?short s1 = 1; s1 += 1;有错吗

对于 short s1 = 1; s1 = s1 + 1;由于 1 是 int 类型,因此 s1+1 运算结果也是 int型,需要强制转换类型才能赋值给 short 型。

而 short s1 = 1; s1 += 1;可以正确编译,因为 s1+= 1;相当于 s1 = (short(s1 + 1);其中有隐含的强制类型转换

类与接口

抽象类和接口的对比

抽象类是用来捕捉子类的通用特性的。接口是抽象方法的集合。

从设计层面来说,抽象类是对类的抽象,是一种模板设计,接口是行为的抽象,是一种行为的规范。
相同点:

  • 接口和抽象类都不能实例化
  • 都位于继承的顶端,用于被其他实现或继承
  • 都包含抽象方法,其子类都必须覆写这些抽象方法
    不同点:
    在这里插入图片描述
    接口和抽象类各有优缺点,在接口和抽象类的选择上,必须遵守这样一个原则:
    行为模型应该总是通过接口而不是抽象类定义,所以通常是优先选用接口,尽量少用抽象类。
    选择抽象类的时候通常是如下情况:需要定义子类的行为,又要为子类提供通用的功能。

普通类和抽象类的区别

  • 普通类不能包含抽象方法,抽象类可以包含抽象方法。
  • 抽象类不能直接实例化,普通类可以直接实例化。

List,Set,Map

在这里插入图片描述
Java的容器分为Collection和Map两大类。Collection集合的子接口有List,Set,Queue三种。
List:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
Set:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。
Map:是一个键值对集合,存储键、值和之间的映射。 Key无序,唯一;value 不要求有序,允许重复。Map没有继承于Collection接口,从Map集合中检索元素时,只要给出键对象,就会返回对应的值对象。
Map 的常用实现类:HashMap、TreeMap、HashTable、LinkedHashMap、ConcurrentHashMap

集合底层数据结构

List :
ArrayList : Object数组
Vector : Object数组
LinkedList : 双向循环链表

Set:
HashSet(无序,唯一):基于HashMap实现的,底层采用Hashmap来保存元素
LinkedHashSet:继承与HashSet,其内部是通过LinkedHashMap实现
TreeSet: (有序,唯一):红黑树

HashMap: JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突).JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间

LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
HashTable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
TreeMap: 红黑树(自平衡的排序二叉树)

线程安全的集合

vector:就比arraylist多了个同步化机制(线程安全),因为效率较低,现在已经不太建议使用。在web应用中,特别是前台页面,往往效率(页面响应速度)是优先考虑的。
statck:堆栈类,先进后出。
hashtable:就比hashmap多了个线程安全。
enumeration:枚举,相当于迭代器。

ArrayList 的优缺点

ArrayList的优点如下:

ArrayList 底层以数组实现,是一种随机访问模式。ArrayList 实现了 RandomAccess 接口,因此查找的时候非常快。
ArrayList 在顺序添加一个元素的时候非常方便。

ArrayList 的缺点如下:

删除元素的时候,需要做一次元素复制操作。如果要复制的元素很多,那么就会比较耗费性能。
插入元素的时候,也需要做一次元素复制操作,缺点同上。
ArrayList 比较适合顺序添加、随机访问的场景。

ArrayList和LinkedList区别

数据结构实现:ArrayList 是动态数组的数据结构实现,而 LinkedList 是双向链表的数据结构实现。
随机访问效率:ArrayList 比 LinkedList 在随机访问的时候效率要高,因为 LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找。
增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比 ArrayList 效率要高,因为 ArrayList 增删操作要影响数组内的其他数据的下标。
内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
综合来说,在需要频繁读取集合中的元素时,更推荐使用 ArrayList,而在插入和删除操作较多时,更推荐使用 LinkedList。

补充:数据结构基础之双向链表

双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。

List 和 Set的区别

List , Set 都是继承自Collection 接口

List 特点:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。

Set 特点:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。

另外 List 支持for循环,也就是通过下标来遍历,也可以用迭代器,但是set只能用迭代,因为他无序,无法用下标来取得想要的值。

Set和List对比

Set:检索元素效率低下,删除和插入效率高,插入和删除不会引起元素位置改变。
List:和数组类似,List可以动态增长,查找元素效率高,插入删除元素效率低,因为会引起其他元素位置改变

Set接口

HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。

HashSet如何检查重复?HashSet是如何保证数据不可重复的?

向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,同时还要结合equles 方法比较。
HashSet 中的add ()方法会使用HashMap 的put()方法。

HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为HashMap 的key,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V。所以不会重复( HashMap 比较key是否相等是先比较hashcode 再比较equals )。

以下是HashSet 部分源码:
在这里插入图片描述
在这里插入图片描述

Map

HashMap概述: HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

HashMap的数据结构: 在Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。

HashMap 基于 Hash 算法实现的

当我们往Hashmap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
存储时,如果出现hash值相同的key,此时有两种情况。(1)如果key相同,则覆盖原始值;(2)如果key不同(出现冲突),则将当前的key-value放入链表中
获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。
需要注意Jdk 1.8中对HashMap的实现做了优化,当链表中的节点数据超过八个之后,该链表会转为红黑树来提高查询效率,从原来的O(n)到O(logn)

HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现

在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。

JDK1.8之前
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
在这里插入图片描述
相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
在这里插入图片描述
在这里插入图片描述

HashMap的put具体流程

当我们put的时候,首先计算 key的hash值,这里调用了 hash方法,hash方法实际是让key.hashCode()与key.hashCode()>>>16进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。按照函数注释,因为bucket数组大小是2的幂,计算下标index = (table.length - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能。
在这里插入图片描述

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

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

//实现Map.put和相关方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 步骤①:tab为空则创建 
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 步骤②:计算index,并对null做处理  
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素
    else {
        Node<K,V> e; K k;
        // 步骤③:节点key存在,直接覆盖value 
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 将第一个元素赋值给e,用e来记录
                e = p;
        // 步骤④:判断该链为红黑树 
        // hash值不相等,即key不相等;为红黑树结点
        // 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
        else if (p instanceof TreeNode)
            // 放入树中
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 步骤⑤:该链为链表 
        // 为链表结点
        else {
            // 在链表最末插入结点
            for (int binCount = 0; ; ++binCount) {
                // 到达链表的尾部
                
                //判断该链表尾部指针是不是空的
                if ((e = p.next) == null) {
                    // 在尾部插入新结点
                    p.next = newNode(hash, key, value, null);
                    //判断链表的长度是否达到转化红黑树的临界值,临界值为8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //链表结构转树形结构
                        treeifyBin(tab, hash);
                    // 跳出循环
                    break;
                }
                // 判断链表中结点的key值与插入的元素的key值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    break;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        //判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值
        if (e != null) { 
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 结构性修改
    ++modCount;
    // 步骤⑥:超过最大容量就扩容 
    // 实际大小大于阈值则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

HashMap的扩容操作

①.在jdk1.8中,resize方法是在hashmap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;

②.每次扩展的时候,都是扩展2倍;

③.扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置。

在putVal()中,我们看到在这个函数里面使用到了2次resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发,但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
        if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值
            threshold = Integer.MAX_VALUE;
            return oldTab;//返回
        }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
    }
    // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂
    // 直接将该值赋给新的容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 新的threshold = 新的cap * 0.75
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 计算出新的数组长度后赋给当前成员变量table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
    table = newTab;//将新数组的值复制给旧的hash桶数组
    // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散
    if (oldTab != null) {
        // 遍历新数组的所有桶下标
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
                oldTab[j] = null;
                // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
                if (e.next == null)
                    // 用同样的hash映射算法把该元素加入新的数组
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // e是链表的头并且e.next!=null,那么处理链表中元素重排
                else { // preserve order
                    // loHead,loTail 代表扩容后不用变换下标,见注1
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead,hiTail 代表扩容后变换下标,见注1
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表
                    do {             
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
                                // 代表下标保持不变的链表的头元素
                                loHead = e;
                            else                                
                                // loTail.next指向当前e
                                loTail.next = e;
                            // loTail指向当前的元素e
                            // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
                            // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
                            // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
                            loTail = e;                           
                        }
                        else {
                            if (hiTail == null)
                                // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

什么是Hash,什么是Hash冲突

Hash一般翻译成“散列”,就是把任意长度的输入通过散列算法,变成固定长度的输出,输出的就是散列值。这是一种压缩映射。简单来说就是将一种任意长度的消息压缩到某一固定长度的消息摘要的函数
基本特性:根据同一个散列函数计算出来的散列值如果不同,那么输入值肯定不同。根据同一个散列函数计算出的散列值如果相同,输入值不一定相同。

Hash冲突 就是当两个不同的输入值,根据同一个散列函数计算出相同的散列值的现象。就是哈希碰撞

HashMap如何处理hash冲突

在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法的方式可以解决哈希冲突
在这里插入图片描述
这样我们就可以将拥有相同哈希值的对象组织成一个链表放在hash值所对应的bucket下,但相比于hashCode返回的int类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,所以我们还需要对hashCode作一定的优化

hash()函数
上面提到的问题,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}

这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);

JDK1.8新增红黑树
在这里插入图片描述
通过上面的链地址法(使用散列表)和扰动函数我们成功让我们的数据分布更平均,哈希碰撞减少,但是当我们的HashMap中存在大量数据时,加入我们某个bucket下对应的链表有n个元素,那么遍历时间复杂度就为O(n),为了针对这个问题,JDK1.8在HashMap中新增了红黑树的数据结构,进一步使得遍历复杂度降低至O(logn);

HashMap 与 HashTable 有什么区别?

1、线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);

2、效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;

3、对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛NullPointerException。

4、**初始容量大小和每次扩充容量大小的不同 **: ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。

5、底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。

ConcurrentHashMap 和 Hashtable 的区别

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

1、底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;

2、实现线程安全的方式(重要): ① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

ConcurrentHashMap 底层具体实现

JDK1.7

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
在这里插入图片描述
1、该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
2、Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

JDK1.8

在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

结构如下:在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值