gem-universe / blog

0 stars 0 forks source link

[Java]基础知识题温习 #21

Open supergem3000 opened 8 months ago

supergem3000 commented 8 months ago

如何判断 Java 工程师的基础知识是否扎实? - 王争的回答 - 知乎 目标:逐步把这些基础题每一道都搞清楚。

supergem3000 commented 8 months ago

1. 程序本质:代码是如何被执行的?CPU、操作系统、虚拟机各司何职?

CPU 无情地执行指令。

操作系统 管理资源、程序调度、提供通用功能的API(系统调用)。

虚拟机 在CPU之上,解释执行代码。虚拟机抹平平台之间的差异,使代码可以跨平台运行。


扩展阅读:Java编程之美-01. 程序本质:代码是如何被执行的?CPU、操作系统、虚拟机各司何职? - 王争的文章 - 知乎

supergem3000 commented 8 months ago

2. 基础语法:从CPU角度看变量、数组、类型、运算、跳转、函数等语法

变量 可看作内存地址的映射。不同的变量有不同的作用域(生命周期)。一般来讲栈存储作用域为函数内的数据,如局部变量、函数参数;堆存储不局限于函数内的数据,如对象,需主动释放或被垃圾回收;常量池一般存储常量等,常量的生命周期和程序的生命周期一样,程序执行结束后内存才会被释放。

数组 一块连续的内存空间,通过下标寻址访问响应内存单元。(不同语言有不同存储方式,如JavaScript可以通过Map实现数组)

类型 CPU视角中没有类型的概念,类型帮助程序员编写正确的代码。类型系统可分为静态类型、动态类型、强类型、弱类型。

运算 绝大部分运算在CPU有对应指令,对应不同的逻辑电路。

跳转 程序由顺序、选择(分支、条件)、循环构成,选择和循环统称跳转。通过跳转指令来实现,类似于goto语法。

函数 代码模块化的一种手段。底层实现需要用到栈。函数调用时,会在栈中创建一个栈帧,存储函数上下文。


扩展阅读:Java编程之美-02. 基础语法:从CPU角度看变量、数组、类型、运算、跳转、函数等语法 - 王争的文章 - 知乎

supergem3000 commented 8 months ago

3. 引用类型:同样都是存储地址,为何Java引用比C/C++指针更安全?

Java基本类型 整形(byte,short,int,long)、浮点型(float,double)、字符型(char)、布尔型(boolean)。引用类型:对象引用、数组引用。

Java中数组其实也是对象,但这个数组对象不是某个类实例化来的,而是由JVM直接创建的,父类也是Object。扩展阅读:JAVA中的数组是对象吗? - 知乎

参数传递 Java中的参数传递是值传递。在C++中,我们可以通过“&”引用语法获取到一个变量的地址,传递给函数,在函数内修改这个地址对应的内存单元,这种传递参数的方式叫做引用传递。Java中的函数参数虽然可以是引用类型,也就是对象的地址,但从引用变量本身的角度来看,传递是引用变量的值(也就是对象的地址)而非引用变量的地址。

数据判等 基本类型直接使用等号==,引用类型使用等号判等地址是否相同,使用equals()方法根据类的重新判等。

如果是一个Integer和一个int,==equals()是一样的,因为Integer会有拆箱的过程。

访问安全 C/C++允许指针越界访问,允许指针做加减运算,允许指针嵌套(指针的指针),甚至允许指针将一块内存重新解读为任意类型。Java语言摒弃了灵活的指针,设计了更加安全的指针,即引用。尽管指针和引用存储的都是某段内存的地址,但是,在用法上,引用是有限制的指针,只能引用对象或数组,并且不能进行加减运算,强制类型转化也只能发生在有继承关系的类之间。


扩展阅读:Java编程之美-3. 引用类型:为什么Java语言中的引用比C/C++语言中的指针更安全? - 王争的文章 - 知乎

supergem3000 commented 8 months ago

4. 基本类型:既然Java一切皆对象,那又为何要保留int等基本类型?

基本类型的一些问题 整形数据范围负数比正数多一个,见“补码表示”;如何用二进制表示浮点数,见“IEEE 754”;boolean类型虽只需1个二进制位就可以表示,但考虑到字节对齐,采用1字节存储操作更简单;char类型长度是2字节,而C/C++是1字节,见UTF-16编码。

理解Java中char类型扩展阅读:Java中关于Char存储中文到底是2个字节还是3个还是4个? - 子非鱼的回答 - 知乎

基本类型转换 自动类型转换也叫隐式转换,从数据范围小的类型像数据范围大的类型转换可以触发。其中boolean比较特殊,不支持转换;char和short虽然都是2字节,但是char的范围是0~65535,不能自动转换为short;long是8字节,float是4字节,但是float数据范围更大,long可以自动转换为float。强制类型转换则可能导致数据截断。

引用类型转换 仅限于有继承关系的类之间。引用类型之间的转换有两种类型:向上转换(Upcasting)和向下转换(Downcasting)。向上转换属于自动类型转换,向下转换属于强制类型转换。

自动装箱拆箱 基本类型和包装类之间可以转换,这种转换可以显式执行,也可以隐式执行。

不恰当的使用自动装箱拆箱,也会导致性能问题。例如:

Integer count = 0;
 for (int i = 0; i < 10000; ++i) {
      count += 1;
 }

因为Integer等包装类是不可变类,执行这条语句会先触发自动拆箱,执行加法操作,然后再触发自动装箱,生成新的Integer类对象,也就是说,要执行10000次自动装箱和拆箱,并且生成10000个Integer对象。

常量池技术 IntegerCache类会缓存值为-128到127之间的Integer对象。当我们通过自动装箱,也就是调用valueOf()来创建Integer对象时,如果要创建的Integer对象的值在-128和127之间,就会从IntegerCache中直接返回,否则才会真正调用new方法创建。它是享元模式的典型应用。

享元模式扩展阅读:享元 - 廖雪峰的官方网站

基本类型 VS 包装类 包装类是引用类型,对象的引用和对象本身是分开存储的,而对于基本类型数据,变量对应的内存块直接存储数据本身。因此,基本类型数据在读写效率方面,要比包装类高效。同时一个int类型数据只占用4字节的内存空间,存储效率也比包装类高效。 项目开发中,首选基本类型。当然也有一些情况适合使用包装类,比如映射数据库的Entity、映射接口请求的DTO,在数据库或请求中的字段值为null时。


扩展阅读:Java编程之美-04. 基本类型:既然Java一切皆对象,那么又为何保留基本类型? - 王争的文章 - 知乎

supergem3000 commented 8 months ago

5. 位运算:>>>和>>有何区别?(原码/反码/补码、算术位移/逻辑位移)

如何在计算机中表示整数 计算机使用二进制数最高位作为符号位,其余位作为数值位。 原码表示法:容易理解,但减法运算复杂。

0111 1111 -> 127
1111 1111 -> -127

补码表示法:负数取反+1

01111111 -> 127
10000001 -> -127

位运算 移位操作分为算术位移和逻辑位移。逻辑位移不区分符号位,它将数据的补码整体往左或往右移动,并在后面或前面补全0。算术左移跟逻辑左移操作相同,对正数进行算术右移,补码在右移之后前面补0;对负数进行算术右移,补码在右移之后前面补1。


5. 位运算:>>>和>>的区别?(原码/反码/补码、算术位移/逻辑位移) - 王争的文章 - 知乎

supergem3000 commented 8 months ago

6. 浮点数:计算机如何用二进制表示浮点数?为何0.1+0.1不等于0.2?

IEEE754标准 对于4字节单精度浮点数,最高位存储S,中间8位存储指数E,叫做指数域,最后23位存储有效数字,叫做有效数字域。对于8字节的双精度浮点数,最高位存储S,中间11位存储指数E,最后52位存储有效数字。

S EEEEEEEE MMMMMMMMMMMMMMMMMMMMMMM

V = (-1)^S * 2^(E-127) * 1.M

浮点数的表示范围和精度 单精度浮点数表示范围是-3.4E38~3.4E38,之所以能表示这么大的数据范围,是因为它有选择地表示这个范围内的一小部分的数,那么就会存在精度问题。当某个实数表示成二进制科学计数法,并且其有效数字位数超过24位时,就会做精度舍弃。

BigDecimal BigDecimal将整数部分和小数部分分开存储,小数部分也当做整数来存储,这样就能精确表示像0.1这样数据了。

BigDecimal bg = new BigDecimal("0.1")

注意传递进BigDecimal中的是字符串“0.1”,而非float类型数据0.1f,否则BigDecimal将无法表示精确的0.1。


6. 浮点数:计算机如何用二进制表示浮点数?为何0.1+0.1不等于0.2? - 王争的文章 - 知乎

supergem3000 commented 8 months ago

7. 字符:为何C/C++中char占1个字节,而Java中char占2个字节?

字符的表示 C/C++中,char只占一个字节,取值范围为-128~127。采用的是ASCII编码标准,ASCII编码每个字符只占用一个字节。

Java中char类型使用16位无符号整数来描述unicode中最早一批收录的字符,这批字符后来被叫作Basic Multilingual Plane(BMP),然后编码表使用的是utf-16。所以,Java中单个char类型描述的字符是有限的,单个char只能描述unicode中的BMP范围的码位,也就意味着BMP范围外的字符char是无法表示的。比如一些emoji

utf-16的历史 Java中char类型目前已经偏离了其原有的语意,也就是一个char类型表示的并不完全是我们理解上的任意一个字符,这是由于Java中char类型所使用的utf-16编码的历史原因造成的。

最开始的utf-16还叫作UCS-2,使用固定两个字节足以表示所有的字符。后来发现两个字节不足以表示unicode新收录的那些字符了。至此utf-16从固定两字节的UCS-2进化为现在不定长的utf-16。utf-16使用4个字节来表示non-BMP中的字符,这4个字节以两个字节为单位划分,也就是可以分为两个16-bit的code unit。这也就导致java单个char无法表示non-BMP字符,也是单个char类型表示non-BMP字符编译不过,而String可以的原因。

正确理解Java中的char char脱离了原有字符的语意,理解为code unit更加合适。一个char等于一个code unit,而不等于一个字符。

String emoji = "🤐";
System.out.println(emoji.length()); // 2

String.length()返回的是code unit的个数,而不是字符的个数,也不是占用字节的个数。统计String中的字符(code point)数,请使用String.codePointCount()方法。


扩展阅读:Java中关于Char存储中文到底是2个字节还是3个还是4个? - 子非鱼的回答 - 知乎 jdk9为何要将String的底层实现由char[]改成了byte[]? - 子非鱼的回答 - 知乎

supergem3000 commented 8 months ago

8. 字符串:请解释String类用到的三大技术:压缩、常量池、不可变

Compact String Java 6引入了Compressed Strings,对于one byte per character使用byte[],对于two bytes per character继续使用char[]不过在Java7被废弃了,然后在Java8被移除。 Java 9引入了Compact Strings来取代Java 6的Compressed Strings,它的实现更过彻底,完全使用byte[]来替代char[],同时新引入了一个字段coder来标识是LATIN1还是UTF16。

Latin1是ISO-8859-1的别名,有些环境下写作Latin-1。Latin1编码是单字节编码,向下兼容ASCII,其编码范围是0x00-0xFF,0x00-0x7F之间完全和ASCII一致,0x80-0x9F之间是控制字符,0xA0-0xFF之间是文字符号。

在JDK9之前,String的底层存储结构是char[],一个char需要占用两个字节的存储单位。据说JDK的开发人员经过调研得出了一个结论:大部分的String都是以Latin-1字符编码来表示的,只需要一个字节存储就够了,两个字节完全是浪费。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    /** The value is used for character storage. */
    @Stable
    private final byte[] value;

    private final byte coder;

    @Native static final byte LATIN1 = 0;
    @Native static final byte UTF16  = 1;

    static final boolean COMPACT_STRINGS;

    static {
        COMPACT_STRINGS = true;
    }
}

从代码可以看到底层的存储已经变成了byte[]。coder代表编码的格式,目前String支持两种编码格式LATIN1和UTF16。LATIN1需要用一个字节来存储。而UTF16需要使用2个字节或者4个字节来存储。 而COMPACT_STRINGS则是用来控制是否开启String的compact功能。默认情况下COMPACT_STRINGS功能是开启的。

常量池 class常量池 我们写的每一个Java类被编译后,就会形成一份class文件;class文件中还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量 (Literal)和 符号引用 (Symbolic References),每个class文件都有一个class常量池。

运行时常量池 运行时常量池存在于内存中,也就是class常量池被加载到内存之后的版本,是方法区的一部分。不同之处是:它的字面量可以动态的添加(String类的intern()),符号引用可以被解析为直接引用。

字符串常量池 在JDK1.7之前,其存在于永久代中,到JDK1.7及之后,已经中永久代移到了堆中。 字符串常量池本质就是一个哈希表,存储的是字符串实例的引用,在被整个JVM共享。在解析运行时常量池中的符号引用时,会去查询字符串常量池,确保运行时常量池中解析后的直接引用跟字符串常量池中的引用是一致的。

不可变 String不可变很简单,给一个已有字符串"abcd"第二次赋值成"abcedl",不是在原内存地址上修改数据,而是重新指向一个新对象,新地址。String类用final关键字修饰,这说明String不可继承。String类的主力成员字段value也是用final修饰的。但value这个数组内部的值怎么保证不可变呢?所有String的方法没有去动Array里的元素,没有暴露内部成员字段。

为什么Java 中 String 类为什么要设计成不可变的?一是前面提到的常量池的原因;二是因为字符串不可变,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算,使得字符串作为 HashMap 中的 key,效率大大提高;三是在多线程中,不变的对象和值是线程安全的,当一个线程”修改“了字符串的值,只会产生一个新的字符串对象,不会对其他线程的访问产生副作用,不需要任何同步操作。


扩展阅读: Java 9 缩小字符串( Compact String) - HoneyMoose的文章 - 知乎 请别再拿“String s = new String("xyz");创建了多少个String实例”来面试了吧 java中这条语句创建了几个对象? - RednaxelaFX的回答 - 知乎 深入解析String#intern - 美团技术团队 从字符串到常量池,一文看懂String类设计 - 程序员DMZ的文章 - 知乎 如何理解 String 类型值的不可变? - 胖君的回答 - 知乎

supergem3000 commented 8 months ago

9. 对象:请描述一下Java对象的内存结构,以及如何统计对象大小?

对象的创建 类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

分配内存: 对象所需的内存大小在类加载完成后便可确定。 分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器。

虚拟机采用两种方式来保证内存分配线程安全

初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)

设置对象头: 这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。

执行 init 方法: 执行 init 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局 对象头: 类型指针和mark word。mark word用于存储对象自身的运行数据,如哈希码(hash code),GC分代年龄,锁状态标识,线程持有的锁,偏向线程ID,偏向时间戳等。类型指针是对象指向它的类型元数据的指针,通过这个指针来确定对象是由哪个类创建的。(如果是数组对象,则对象头还有数组长度部分)

Klass Pointer 类型指针支持指针压缩。原理是Java中对象默认使用了8字节对齐,使得32位指针最大能表示2^32*8=32GB内存。所以如果配置的最大的堆内存超过这个数值时,那么指针压缩将会失效。

实例数据: 对象真正存储的有效信息,保存了代码中定义的各种数据类型的字段内容。

对齐填充字节: Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

如何计算对象大小 除了对象头比较固定的大小外,实例数据大小的计算,还涉及字段重排序、继承父类等细节,最后加上对齐填充。可参考扩展阅读。


扩展阅读: 图文详解Java对象内存布局 - 知乎用户DVSNCA的文章 - 知乎 HotSpot 虚拟机对象探秘

supergem3000 commented 8 months ago

10. 关键字:静态内部类实现的单例如何做到线程安全且可延迟加载?

饿汉式单例模式

public class Singleton{
    private static Singleton instance = new Singleton();

    private Singleton() {}

    public static Singleton getInstance() {
        return instance;
    }
}

实例在类加载时已经被创建,因此线程安全,但可能会浪费资源。 之所以是线程安全的,是因为JVM在类加载的过程,保证了不会初始化多个static对象。

懒汉式单例模式

public class Singleton {
    private static Singleton instance = null;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if(instance == null){
            instance = new Singleton();
        }
        return instance;
    }
}

这种方式每次获取实例对象时都需要进行加锁检查,可能会影响性能。

public class Singleton {
    private static Singleton instance = null;

    private Singleton(){}

    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

对部分代码同步包裹,这样在instance存在后获取,不会涉及到锁。这种方式叫双重检查(Double-checking Locking,DCL)看起来是正确的,但实际上存在问题:存在一个线程将instance初始化完成后,另一个线程没有看到这个变化的可能;JVM可能进行指令重排,instance初始化还未完成,另一个线程已经看到instance,获得的是半成品。 这里还需要加上volatile关键字,才正确。

private static volatile Singleton instance = null;

JMM中关于synchronized有如下规定,线程加锁时,必须清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;线程在解锁时,需要把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性。 所以这里其实synchronized也可以保证可见性,这里volatile主要是防止指令重排。synchronized没有禁止指令重排序,对象还没有初始化,地址已经刷新到主存了。其他线程拿到对象地址,去访问对象变量,结果变量还没初始化结束。所以要加volatile。可以查看知乎的一个讨论:synchronized 关键字可以保证可见性吗? - minato丶的回答 - 知乎

volatile关键字 volatile的作用:保证变量的可见性、禁止指令重排。 JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。对于普通的共享变量来讲,线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。 重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

内部类与静态内部类

Nested classes are divided into two categories: static and non-static. Nested classes that are declared static are called static nested classes. Non-static nested classes are called inner classes.

静态内部类可以理解为嵌套类,只是一层嵌套,和外部类没有什么关系。只能访问静态的外部属性和方法。 非静态内部类,需要依托于外部类的实例,也能访问外部类的实例的属性和方法。内部类对象是以外部类对象存在为前提的。

class Outer {
    class Inner {}
    static class StaticInner {}
}

Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
Outer.StaticInner inner0 = new Outer.StaticInner();

静态内部类实现线程安全的单例模式

public class Singleton {

    private Singleton() {
    }

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

    public static Singleton getInstance() {
        return SingletonHolder.singleton;
    }
}

静态内部类与外部类没有什么大的关系,外部类加载的时候,内部类不会被加载。当调用 Singleton.getInstance() 时,内部类 SingletonHolder 才会初始化,静态变量 singleton 被创建出来。


扩展阅读: DCL(双检锁)的失效:现实与初衷的背离 - 赫尔修斯 - 博客园 (cnblogs.com) Nested Classes (The Java™ Tutorials > Learning the Java Language > Classes and Objects) (oracle.com) java涉及内部类加载的问题。在加载了外部类的时候,内部类也会随之加载吗? - RednaxelaFX的回答 - 知乎

supergem3000 commented 8 months ago

11. 容器:为什么不推荐在项目中使用Vector、Stack、HashTable?

Vector Vector与ArrayList最大的不同点在于它是线程安全的,因为其内部几乎所有方法都用了synchronized来修饰。但是,synchronized是重量级锁,读写操作也没有做适当的并发优化,已经被并发性更好的CopyOnWriteArrayList取代了。当不要求线程安全时,自然会选择ArrayList,如果要求线程安全,往往也会选择CopyOnWriteArrayList或其他方法。 Vector空间满了之后,扩容是一倍,而ArrayList仅仅是一半。

事实上,在每个操作方法用synchronized,也并不能保证总体上的线程安全,并且还慢。

Stack Stack是Vector的子类,其内部的方法也都是通过无脑加synchronized来实现的。并且Stack继承了Vector,有能力在数组中的任何位置添加或者删除元素,破坏了栈这种数据结构的封装。

实际上,它犯了面向对象设计领域的一个基本错误:Stack 和 Vector 之间的关系,不应该是继承关系,而应该是组合关系。

Java官方示例,建议使用Deque(双端队列)作为栈。不过有和Vector一样的问题,破坏了封装。但这属于历史问题了,无解。

Hashtable Hashtable同样是给每一个操作方法加上了synchronized,导致性能低下。同时Hashtable要求key和value不能为null。

Hashtable当key为null时,获取hashCode会报NPE,而HashMap做了特殊处理,当key为null时计算的hashCode为0。 当value为null时,HashMap可以通过containsKey判断究竟是不包含key,还是这个key对应的value就是null。而Hashtable和ConcurrentHashMap作为支持多线程的容器,containsKey和get是两步操作,当get的时候,之前containsKey的判断可能已经失效。出于并发安全性的考虑,Hashtable 和 ConcurrentHashMap 不允许 key 和 value 为 null。

如果想保证HashMap线程安全,可以使用Collections.synchronizedMap包装一下,或使用ConcurrentHashMap。

Collections.synchronizedMap方法,会创建一个内部类对象,并且内部存在一个互斥锁。与Hashtable不同的是,SynchronizedMap是代码块基本的锁,但是可以锁对象,而Hashtable是锁方法级别。


扩展阅读: Why is Java Vector (and Stack) class considered obsolete or deprecated? - Stack Overflow Java Stack vs Deque - bad.robot (baddotrobot.com) Hashtable 渐渐被人们遗忘了,只有面试官还记得,感动 - 飞天小牛肉 - 博客园 (cnblogs.com)

supergem3000 commented 8 months ago

12. 容器工具类:TimSort和DualPivotQuickSort的实现原理和区别

待续

supergem3000 commented 8 months ago

13. HashMap(上):为何HashMap中数组的大小必须是2的幂次方?

HashMap计算

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

三步:>>>无符号右移、^异或、&与。

加快运算 为了找到 KEY 的位置在哈希表的哪个槽里面,需要计算hash(KEY) % length。但是%运算比&运算慢,当length为2的幂次方时,hash % length等于hash & (length - 1)。 HashMap的长度一定是2的次幂,扩容后,元素新的位置,要么在原脚标位,要么在原脚标位+扩容长度的位置。

为什么要使用高16位异或低16位计算Hash值 hash值其实是一个int类型,二进制位为32位,而HashMap的table数组初始化size为比较小(如16),取余操作为hashCode & 1111,hashCode的高位其实并没有参与运算,导致很多hash值地位相同而高位有区别的数,算出来的索引是一样的。为了避免这种情况,HashMap将高16位与低16位进行异或,这样可以保证高位的数据也参与到与运算中来,以增大索引的散列程度,让数据分布得更为均匀。

但是重新想一下,hashCode计算的比较好的话,只看低位,应该也是均匀的才对。也许是为了避免重写的hashCode算法不好导致hashCode不均匀,所以进行了第二次hash。

为什么使用异或计算 异或可以保证两个数值的特性,&运算使得结果向0靠近,|运算使得结果向1靠近。

题外话 很多文章都提到,数组长度为2的幂次可以减少冲突。他们的论证过程是:如果数组长度为奇数,那么长度减1后为偶数,使用hash & (length - 1)运算时,得到的结果的最低位肯定是0,也就是说结果一定是偶数。这样一来,任何哈希值都只会映射到数组的偶数索引上,导致了空间的浪费。然而,我认为这个论证是不正确的。因为将哈希值映射到数组索引上本应该使用取余操作,只有当数组长度是2的幂次时,我们才能利用位运算&来加速计算。如果数组长度不是2的幂次,那么我们根本不应该使用位运算&!"

supergem3000 commented 8 months ago

14. HashMap(下):为何链表树化的阈值为8,默认装载因子是0.75?

HashMap的结构 最开始的Map是空的,往里放元素时会占用一个通的未知。如果后续计算发现需要落到同一个桶中,那么便会用链表的形式往后延长,俗称“拉链法”。 当链表长度大于等于阈值(默认为8),同时还满足容量大于或等于 MIN_TREEIFY_CAPACITY(默认为 64)的要求,就会把链表转换为红黑树。 后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态。

为什么不直接用红黑树? JDK源码注释:

Because TreeNodes are about twice the size of regular nodes,
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due 
removal or resizing) they are converted back to plain bins.

TreeNode节点是普通节点的两倍大。最开始使用链表的时候,空间占用是比较少的,而且由于链表短,所以查询时间也没有太大的问题。可是当链表越来越长,才需要用红黑树的形式来保证查询的效率。

为什么树化阈值是8? 如果 hashCode 分布良好,那么红黑树这种形式是很少会被用到的。当长度为 8 的时候,概率仅为 0.00000006(与负载因子有关,见下文)。通常情况下,并不会发生从链表向红黑树的转换。长度超过 8 就转为红黑树的设计,更多的是为了防止用户自己实现了不好的哈希算法时导致链表过长,从而导致查询效率低, 而此时转为红黑树更多的是一种保底策略,用来保证极端情况下查询的效率。

为什么负载因子是0.75? 负载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。他的作用很简单,相当于是一个扩容机制的阈值。当超过了这个阈值,就会触发扩容机制。

负载因子过大(比如1)时,只有当数组的值全部填充了才会扩容,则导致Hash冲突的数量较大,对查询效率不利,牺牲了时间来保证空间的利用率。 负载因子过小(比如0.5),意味着当数组中的元素达到了一半就开始扩容,Hash冲突较少,但空间利用率会大大降低。 0.75这个值是时间与空间的权衡

负载因子和树化阈值二者也是有紧密联系的,JDK注释如下:

In usages with well-distributed user hashCodes, tree bins 
are rarely used.  Ideally, under random hashCodes, the 
frequency of nodes in bins follows a Poisson distribution 
(http://en.wikipedia.org/wiki/Poisson_distribution) with a 
parameter of about 0.5 on average for the default resizing 
threshold of 0.75, although with a large variance because 
of resizing granularity. Ignoring variance, the expected 
occurrences of list size k are (exp(-0.5) * pow(0.5, k) / 
factorial(k)). The first values are:

 0:    0.60653066
 1:    0.30326533
 2:    0.07581633
 3:    0.01263606
 4:    0.00157952
 5:    0.00015795
 6:    0.00001316
 7:    0.00000094
 8:    0.00000006
 more: less than 1 in ten million

对照桶中元素个数和概率的表,可以看到当用 0.75 作为加载因子时,桶中元素到达 8 个的时候,概率已经变得非常小。


扩展阅读: hashmap中为何链表长度大于8才转换成红黑树 - 知乎 (zhihu.com) hashmap为什么要有负载因子? - 一瓶小可乐的回答 - 知乎

supergem3000 commented 8 months ago

15. LinkedHashMap:如何使用LinkedHashMap实现LRU缓存?

LinkedHashMap继承自HashMap。

HashMap采用数组加链表的结构存储数据,存储节点为HashMap.Node,分别存放hash值,key,value,以及指向下一个Node节点的next指针,链表结构是单项链表,HashMap并没有维护有序性。

LinkedHashMap继承了HashMap,也是采用了数据加链表的结构,不同的是LinkedHashMap的存储节点(Entry)继承自HashMap.Node,多维护了before和after指针,用来指向上一个和下一个节点,实现双向链表。这个双向链表就可以实现Map有序性。 LinkedHashMap构造器accessOrder参数默认为false,即维护插入顺序(insertion-order);设置为true时,维护访问顺序(access-order)。 当设置为访问顺序时,在get/put时候调用afterNodeAccess方法,将节点移动到队尾。

在有序性的基础上,LinkedHashMap 提供了维护了淘汰数据能力,并开放了淘汰判断的接口removeEldestEntry()。在每次添加数据时,会回调removeEldestEntry()接口,开发者可以重写这个接口决定是否移除最早的节点(在 FIFO 策略中是最早添加的节点,在 LRU 策略中是最早未访问的节点)。

简单的实现如下:

public class LRULinkedHashMap<K, V> extends LinkedHashMap<K, V> {

    private int maxEntries;

    public LRULinkedHashMap(int maxEntries) {
        super(16, 0.75f, true);
        this.maxEntries= maxEntries;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > maxEntries;
    }
}
supergem3000 commented 8 months ago

16. 迭代器:为什么使用迭代器遍历容器的同时修改容器会出错?

并发修改异常 在用迭代器遍历容器的时候,试图去直接修改容器中的元素,可能会引起并发修改异常(ConcurrentModificationException)。这个异常是由迭代器对象抛出的。而出现该异常的原因是集合在迭代器运行期间添加/删除了元素,这会导致迭代器预期的迭代次数发生改变,导致迭代器的结果不准确。 如果是要删除元素,可以使用迭代器本身的删除方法,而不是容器的删除方法。

it.remove();

Iterator与ListIterator 相同点: 都是迭代器,当需要对集合中元素进行遍历不需要干涉其遍历过程时,这两种迭代器都可以使用。

不同点: Iterator可以应用于所有的集合,Set、List和Map和这些集合的子类型。而ListIterator只能用于List及其子类型。 ListIterator有add方法。 ListIterator和Iterator都有hasNext()和next()方法,可以实现顺序向后遍历,但是ListIterator有hasPrevious()和previous()方法,可以实现逆向(顺序向前)遍历。Iterator不可以。 ListIterator可以定位当前索引的位置,nextIndex()和previousIndex()可以实现。 都可实现删除操作,但是ListIterator可以实现对象的修改,set()方法可以实现。Iterator仅能遍历,不能修改。

supergem3000 commented 8 months ago

17. 异常(上):在项目开发中如何正确的定义、处理、打印异常?

异常分类 image Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。 Error:Error 属于程序无法处理的错误,不建议通过catch捕获。例如虚拟机运行错误、内存不足等。 检查异常:也称为“编译时异常”,编译器在编译期间检查的那些异常。如果抛出检查异常,那么编译器会报错,需要开发人员手动处理该异常,要么捕获,要么重新抛出。除了 RuntimeException 之外,所有直接继承 Exception 的异常都是检查异常。 非检查异常:也称为“运行时异常”,编译器不会检查运行时异常,在抛出运行时异常时编译器不会报错,当运行程序的时候才可能抛出该异常。Error及其子类和RuntimeException 及其子类都是非检查异常。

自定义异常:自定义检查异常只需要继承 Exception,自定义非检查异常只需要继承 RuntimeException。

try-catch-finally try:用于捕获异常。 catch:用于处理异常。 finally:无论是否捕获或处理异常,finally 块里的语句都会被执行。

注意:不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。

finally 中的代码一定会执行吗?不一定,比如宕机、线程死亡等等。

supergem3000 commented 8 months ago

18. 异常(下):高并发下异常太多导致程序变慢的核心原因是什么?

待续