一些面试中和实际用到的 Java 底层 知识点.

基础知识

==equals 区别
  1. 首先的区别是,equals 是方法,而 == 是操作符;

    • 这意味着:对于基本类型的变量来说(如 shortintlongfloatdouble),只能使用 == ,因为这些基本类型的变量没有 equals 方法。对于基本类型变量的比较,使用 == 比较, 一般比较的是它们的值。
    • 对于引用类型的变量来说(例如 String 类)才有 equals 方法,因为 String 继承了 Object 类, equals 是 Object 类的通用方法。
  2. equals在object类源码:
    说明里面使用的就是 == 比较,所以这种情况下比较的就是它们在内存中的存放地址

    1
    2
    3
    public boolean equals(Object obj) 
    { return (this == obj);
    }
  3. 对于 equals 方法被重写的情况。以 String 类为例,以下是 String 类中的 equals 方法:

    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
      @Override 
    public boolean equals(Object other) {
    if (other == this) {
    return true;
    }
    if (other instanceof String) {
    String s = (String)other;
    int count = this.count;
    if (s.count != count) {
    return false;
    }
    if (hashCode() != s.hashCode()) {
    return false;
    }
    char[] value1 = value;
    int offset1 = offset;
    char[] value2 = s.value;
    int offset2 = s.offset;
    for (int end = offset1 + count; offset1 < end; ) {
    if (value1[offset1] != value2[offset2]) {
    return false;
    }
    offset1++;
    offset2++;
    }
    return true;
    } else {
    return false;
    }
    }

    从源码可以看出, String 类复写了 equals 方法,当使用 == 比较内存的存放地址不相等时,接下来会比较字符串的内容是否 相等,所以 String 类中的 equals 方法会比较两者的字符串内容是否一样

  4. 在Java中为了方便面向对象,采用了==引用数据类型/包装数据类型==
    基本数据类型,包装数据类型不包括 String
    在这八个类名中,除了 IntegerCharacter 类以后,其它六个类的类名和基本数据类型一致,只是类名的第一个字母大写即可。

基本数据类型 包装类
byte Byte
boolean Boolean
short Short
char Character
int Integer
long Long
float Float
double Double

这里面涉及到一个拆箱装箱的过程,详见:自动拆装箱 (gitee.io)

简单来说:

如果一个变量 p 的值是:

  • -128 至 127 之间的整数 (§3.10.1)
  • true 和 false 的布尔值 (§3.10.3)
  • \u0000 至 \u007f 之间的字符 (§3.10.4)
    范围内的时,将 p 包装成 a 和 b 两个对象时,可以直接使用 a == b 判断 a 和 b 的值是否相等。

以上部分被缓存到了内存,且只适用自动装箱而不是new一个对象,在自动装箱时候会对应到缓存中的项目,如此直接用==比较的即使是地址值也能获得正确的结果.


结论:

  1. ==本身作为比较运算符,用于基本数据类型,因为比较数据类型不存在对象,所以采用比较。
  2. ==本身作为比较运算符,用于对象,因为对象存在特定地址,所以采用地址比较.
  3. equals,所以未重写的,作为方法调用的是==运算,所以采用地址比较.
  4. equals,有过未重写的,作为方法调用的特定方法,所以有特定的比较方式.
  5. IntegerInteger 的对比在 非new, 非-128到+127 的范围可以以等与理解.

参考来源:
Java成神之路

JVM

首先,java内存模型和java内存结构的区别
Java内存结构
Java内存模型

(1)java内存结构是解决java中的数据如何存放的问题。
(2)java内存模型是解决java中多个线程共享数据的问题。

参考链接:深入分析java内存模型(注意和java内存结构的区别) - 知乎 (zhihu.com)

Java内存结构

java内存结构
对应几部分分别是:

  • 线程独享的:程序计数器,Java虚拟机栈,本地方法栈
  • 线程共享的:堆, 方法区/元空间
程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,由于JVM可以并发执行线程,因此会存在线程之间的切换,而这个时候就程序计数器会记录下当前程序执行到的位置,以便在其他线程执行完毕后,恢复现场继续执行。

可以理解为记录代码行号

  • Java方法,这个计数器记录的是正在执行虚拟机字节码指令的地址
  • Native方法,计数器的值则为空(undefined)
Java虚拟机栈

每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
虚拟机栈是每个线程独有的,随着线程的创建而存在,线程结束而死亡。

可以理解为 程序中用到的方法运行时的存储:每个方法享有一个栈帧, 用栈结构实现
在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

参考链接:全网最全JVM虚拟机栈讲解,一文弄懂先进后出的原理-工具-站长头条 (seoxiehui.cn)

  • 局部变量表(local variables)
    • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
    • 局部变量表,最基本的存储单元是Slot(变量槽)
    • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
  • 操作数栈
    • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。
    • 栈中的任何一个元素都是可以任意的Java数据类型。 32bit的类型占用一个栈单位深度 64bit的类型占用两个栈单位深度
    • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作
    • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
    • 大概就是用来对局部变量之间进行计算
    • 有个栈顶缓存技术提高效率
  • 动态链接(或指向运行时常量池的方法引用)
    • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令
    • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
  • 方法出口
    • 存放调用该方法的pc寄存器的值。
    • 一个方法的结束,有两种方式: 正常执行完成 出现未处理的异常,非正常退出
    • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。
      • 方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
      • 而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
    • 方法退出的过程相当于弹出当前栈帧。
本地方法栈

Java虚拟机栈是调用Java方法;本地方法栈是调用本地native方法,可以认为是通过 JNI (Java Native Interface) 直接调用本地 C/C++ 库,不受JVM控制。
本地方法栈调用

参考链接:(18条消息) 一文读懂 -Java虚拟机的内存结构(图详解)_辰兮要努力的博客-CSDN博客_虚拟机内存图

  • 所有线程共享的一块内存区域,唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
  • 垃圾回收(GC)
    • 分代回收算法
    • 新生代(Young Generation)主要是用来存放新生的对象。新生代又被进一步划分为Eden区(伊甸园区)和Survivor区(幸存区,包含空间相等的S0、S1区,或者说From、To区,没有先后顺序,是Copying算法的需要)。大多数情况下,java中新建的对象都是在新生代上分配的,通过Copying算法来进行分配内存和垃圾回收。
    • 老生代(Old Generation)主要存放应用程序中生命周期长的内存对象。在新生代中经过多次垃圾回收仍然存活的对象,会被存放到老生代中。老生代通过标记/整理算法来清理无用内存。
  • 针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
    • 部分收集 (Partial GC):
      • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
      • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
      • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
    • 整堆收集 (Full GC):收集整个 Java 堆和方法区
  • 死亡对象判断方法
    • 引用计数法
      • 给对象中添加一个引用计数器:
        每当有一个地方引用它,计数器就加 1;
        当引用失效,计数器就减 1;
        任何时候计数器为 0 的对象就是不可能再被使用的。
      • 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
    • 可达性分析算法
      • 通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收
      • 即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
方法区/元空间

方法区是JVM规范概念,而永久代则是Hotspot虚拟机特有的概念,简单点理解:方法区和堆内存的永久代其实一个东西,但是方法区是包含了永久代。
只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”
永久代与元空间:均是方法区的实现。永久代将大多数据放在堆内存上,容易诱发OOM。
JDK 1.8之后废弃永久代使用元空间,将方法区数据放在本地内存,理论上可仅受系统内存限制。

方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。

存放有运行时常量池,以及class加载后的产物(类字节码、class/method/field等元数据对象、static-final常量、static变量),以及JIT过程的生成的代码

  • 运行时常量池

    • 字节码文件–class文件中包含了很多信息,比如魔数、属性表、方法表等,其中还有一个常量池,常量池则包含了这个类所用到的各种字面量和引用量
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      class A {
      final int a = 0;
      String s = "hhh"
      String s1 = "aa" + s
      void test() {
      String s2 = "ssss"
      }
      }

      //字面量
      //这个好理解,其实就是 各种 文本字符串,以及基本数据类型,final 常量等,注例如
      //那么 0、"hhh"、"aa"、"ssss" 都属于字面量

      //引用量:
      //也就是符号引用,类和接口的全限定名、字段的名称和描述符、方法的名称和描述符
      //还是上面的Class A
      //那么 类全限定名
      //packagename.A、a、test、packagename.A.test:()V这些就是符号引用

  • 字符串常量池(不在永久代)

    • 字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
      位置
  • JIT代码缓存

JIT, 即时编译.JVM通过解释器java字节码(.class文件)执行时,由于效率问题,引入JIT,即JVM发现某个代码块运行频繁,则会将其编译为相关的机器码,存在方法区,供下次使用。
参考链接:JVM内存结构概念解析 - 简书 (jianshu.com)

集合

List,Set,继承Collection接口
Map单独接口

LIst,Set相关

特点
List 元素有放入顺序,元素可重复
Set 元素无放入顺序,元素不可重复。

List

种类 特点
ArrayList 一个可改变大小的数组 更多的元素加入到ArrayList中时,其大小将会动态地增长.内部的元素可以直接通过get与set方法进行访问
LinkedList 一个双链表 在添加和删除元素时具有比ArrayList更好的性能.但在get与set方面弱于ArrayList.
Vector 一个可改变大小的数组(java.util包) 和ArrayList类似,但属于强同步类
SynchronizedList java.util.Collections中的一个静态内部类 使用Collections.synchronizedList(List list)方法来返回一个线程安全的List
  • Vector和ArrayList在更多元素添加进来时会请求更大的空间。Vector每次请求其大小的双倍空间,而ArrayList每次对size增长50%.
  • LinkedList 还实现了 Queue 接口,该接口比List提供了更多的方法,包括 offer(),peek(),poll()等.
SynchronizedList和Vector的区别
  • add方法
  1. Vector使用同步方法实现,synchronizedList使用同步代码块实现。
  2. 两者的扩充数组容量方式不一样(两者的add方法在扩容方面的差别也就是ArrayList和Vector的差别。)
  • remove方法
  1. 除了一个使用同步方法,一个使用同步代码块之外几乎无任何区别。

    通过比较其他方法,我们发现,SynchronizedList里面实现的方法几乎都是使用同步代码块包上List的方法。
    如果该List是ArrayList,那么,SynchronizedList和Vector的一个比较明显区别就是一个使用了同步代码块,一个使用了同步方法。

  • 数据增长区别
    Vector缺省情况下自动增长原来一倍的数组长度,ArrayList是原来的50%

  • 同步代码块和同步方法的区别

1.同步代码块在锁定的范围上可能比同步方法要小,一般来说锁的范围大小和性能是成反比的。 
2.同步块可以更加精确的控制锁的作用域(锁的作用域就是从锁被获取到其被释放的时间),同步方法的锁的作用域就是整个方法。
3.同步代码块可以选择对哪个对象加锁,但是静态方法只能给this对象加锁。

因为SynchronizedList只是使用同步代码块包裹了ArrayList的方法,而ArrayList和Vector中同名方法的方法体内容并无太大差异,所以在锁定范围和锁的作用域上两者并无区别。
在锁定的对象区别上,SynchronizedList的同步代码块锁定的是mutex对象,Vector锁定的是this对象

那么mutex对象又是什么呢? 其实SynchronizedList有一个构造函数可以传入一个Object,如果在调用的时候显示的传入一个对象,那么锁定的就是用户传入的对象。如果没有指定,那么锁定的也是this对象。


 SynchronizedList和Vector最主要的区别:
 
 1.SynchronizedList有很好的扩展和兼容功能。他可以将所有的List的子类转成线程安全的类。 
 2.使用SynchronizedList的时候,进行遍历时要手动进行同步处理。 
 3.SynchronizedList可以指定锁定的对象。
 —

Set

方法 实现 Null问题
TreeSet 二叉树实现 不允许放入 null值
HashSet 哈希表实现 可以放入 null值,但只能放入一个null
  • 在==HashSet==中,基本的操作都是由HashMap底层实现的,因为HashSet底层是用HashMap存储数据的.当向HashSet中添加元素的时候,首先计算元素的hashCode值,然后通过扰动计算和按位与的方式计算出这个元素的存储位置,如果这个位置为空,就将元素添加进去;如果不为空,则用equals方法比较元素是否相等,相等就不添加,否则找一个空位添加。
  • **==TreeSet==的底层是TreeMap的keySet()**,而TreeMap是基于红黑树实现的,红黑树是一种平衡二叉查找树,它能保证任何一个节点的左右子树的高度差不会超过较矮的那棵的一倍。
  • ==TreeMap==是按key排序的,元素在插入TreeSet时compareTo()方法要被调用,所以TreeSet中的元素要实现Comparable接口。TreeSet作为一种Set,它不允许出现重复元素。TreeSet是用compareTo()来判断重复元素的。
HashMap, HashTable, ConcurrentHashMap
方法 同步 Null问题
HashMap 默认情况下是非同步 null可以作为键,这样的键只有一个;
可以有一个或多个键所对应的值为null。
HashTable 同步 HashTable中,key和value都不允许出现null值
ConcurrentHashMap 线程安全 null可以作为键,这样的键只有一个;
可以有一个或多个键所对应的值为null。
HashMap的容量、扩容

HashMap成员变量
主要关注的: sizeloadFactorthresholdDEFAULT_LOAD_FACTORDEFAULT_INITIAL_CAPACITY

  • transient int size;     -   记录了Map中KV对的个数
    
  • loadFactor 
    • 装载因子,用来衡量HashMap满的程度。loadFactor的默认值为0.75f(static final float DEFAULT_LOAD_FACTOR = 0.75f;)。
  • int threshold; 
    • 临界值,当实际KV个数超过threshold时,HashMap会将容量扩容,threshold=容量*装载因子
  • 除了以上这些重要成员变量外,HashMap中还有一个和他们紧密相关的概念:capacity 
    • 容量,如果不指定,默认容量是16(static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;)
      即:
  1. 默认情况下,一个HashMap的容量(capacity)是16.但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(1->1、7->8、9->16)
  2. 当达到扩容条件时会进行扩容,从16扩容到32、64、128…
    • HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。
    • threshold = loadFactor (装载因子,默认值为0.75f) * capacity
    • 对于一个默认的HashMap来说,默认情况下,当其size大于12(16 * 0.75)时就会触发扩容。
HashMap的哈希冲突解决

参考链接:
HashMap中hash方法的原理 (gitee.io)
哈希表(HashTable)的构造方法和冲突解决 - 简书 (jianshu.com)
(18条消息) Map 综述(一):彻头彻尾理解 HashMap_书呆子Rico的博客-CSDN博客_map

  1. Hash 是什么

数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易,将数组和链表组合在一起,发挥了两者的优势,我们可以将其理解为链表的数组
Hash表

  • Hash()方法-1
    • hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标。
      • 要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap或者HashTable的容量进行取模就行了
      • (2^n - 1)保证首位为0:符号稳定

        此处用 取模和位与运算
        那么,为什么可以使用位运算(&)来实现取模运算(%)呢?这实现的原理如下:
        X % 2^n = X & (2^n - 1)
        2^n表示2的n次方,也就是说,一个数对2^n取模 == 一个数和(2^n - 1)做按位与运算 。
        假设n为3,则2^3 = 8,表示成2进制就是1000。2^3 -1 = 7 ,即0111。
        此时X & (2^3 - 1) 就相当于取X的2进制的最后三位数。
        从2进制角度来看,X / 8相当于 X >> 3,即把X右移3位,此时得到了X / 8的商,而被移掉的部分(后三位),则是X % 8,也就是余数。

  • Hash()方法-2
    • 产生冲突的两个hashcode,经过扰动计算之后,最终得到的index的值不一样
    • 高位的特征和低位的特征组合起来,降低哈希冲突的概率
      Hash冲突
      Hash扰动:右移
  1. HashMap存取

在存储的过程中,系统根据key的hash值来定位Entry在table数组中的哪个桶,并且将其放到对应的链表的链头;
在取的过程中,同样根据key的hash值来定位Entry在table数组中的哪个桶,然后在该桶中查找并返回。

在Java 8 之前,HashMap和其他基于map的类都是通过==链地址法==解决冲突,它们使用单向链表来存储相同索引值的元素。在最坏的情况下,这种方式会将HashMap的get方法的性能从O(1)降低到O(n)
为了解决在频繁冲突时hashmap性能降低的问题,Java 8中使用==平衡树==来替代链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)提高到O(logn)

当链表长度大于阈值(默认为 8)时,会首先调用 treeifyBin()方法。这个方法会根据 HashMap 数组来决定是否转换为红黑树。只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。
参考链接:HashMap源码&底层数据结构分析 | JavaGuide

Java8 HashMap