Java基础

161 阅读9分钟

参考:天天用的 HashMap 还不懂原理?万字图文带你透彻解读

HashMap

1.HashMap底层数据结构

  • JDK1.7 HashMap是以数组+链表的形式组成
  • JDK1.8以后是以数组+链表+红黑树的组成结构
    • 当链表长度大于8 并且 数组长度大于64时,链表结构会转换成红黑树
  • HashMap中每一个元素又称为哈希桶,即key-value的实例,在jdk1.7中叫Entry,JDK1.8中叫Node

image.png

  • Hash桶中包含了4个字段:hash、key、value、next指针

2.get(key)方法原理

  • 对key进行hash计算,找到对应的hash桶,为空直接返回null
  • 判断第一个元素是否是要查询的元素(hash值相等+key值相等),相等直接返回值
  • 如果第一个节点是树结构(first instanceod TreeNode),直接用getTreeNode搜索红黑树。
  • 否则(即为链表结构)直接循环遍历(hash值相等+key值相等)查找。

3.put(key,value)方法原理

  • 对key进行hash计算,找到对应的hash桶,如果为空,直接插入(tab[i] = newNode(hash,key,value,null))
  • 先判断hash桶第一个元素是否是对应的已存在的key(hash值相等+key值相等),是的话直接覆盖。
  • 若不相等,则判断当前是链表还是红黑树结构,红黑树就按照红黑是的方式插入数据,若存在key则覆盖。
  • 链表的话就遍历处理,遍历到链表尾部增加元素(若已存在key则覆盖),然后会判断当前链表的数量是否大于8,是的话直接将链表转换为红黑树
  • 最后判断是否超过最大容量(数量超过threshold),超过则直接扩容。

image.png

4.扩容resize()方法原理

什么时候扩容

  • threashold(临界值) = loadFactor(负载因子)*capacity(容量大小)
  • 负载因子默认:0.75 容量大小默认:16
  • 即元素个数达到12时扩容,扩容大小是原来的2倍

主要分2步:

  • 扩容:创建一个新的Entry数组,长度是原数组的2倍(newCap = oldCap << 1)
  • 位运算:原来的元素hash值和原数组长度进行 & 运算,将元素迁移到新的数组中。

JDK1.7会重新计算每个元素的hash值,而jdk1.8通过高位运算(e.hash & oldCap)来确定是否需要移动,比如:

假设key1的信息如下:
key1.hash=10 -> 二进制:0000 1010
oldcap=16    -> 二进制:0001 0000
e.hash & oldCap=0,因为key1的高1位是0,所以这个key在扩容时不会有变化。

假设key2信息如下:
key2.hash=17 -> 二进制:0001 0001
oldcap=16    -> 二进制:0001 0000
e.hash & oldCap=0,因为key2的高1位是1,所以元素在扩容时位置要发生变化,
新下标=原下标位置+原数组长度

image.png

HashMap的一些属性

// HashMap 初始化长度16  
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4// aka 16  
  
// HashMap 最大长度  
static final int MAXIMUM_CAPACITY = 1 << 30// 1073741824  
  
// 默认的加载因子 (扩容因子)  
static final float DEFAULT_LOAD_FACTOR = 0.75f;  
  
// 当链表长度大于此值且数组长度大于 64 时,会从链表转成红黑树  
static final int TREEIFY_THRESHOLD = 8;  
  
// 转换链表的临界值,当元素小于此值时,会将红黑树结构转换成链表结构  
static final int UNTREEIFY_THRESHOLD = 6;  
  
// 最小树容量  
static final int MIN_TREEIFY_CAPACITY = 64;

为什么HashMap初始化长度为16

主要是为了位运算的方便,位与运算比算数计算的效率高,之所以选择 16,是为了服务将 Key 映射到 index 的算法

有公式(Length 是 HashMap 的长度):HashCode(Key) & (Length- 1)

key 为 "book" 的十进制为 3029737 那二进制就是 101110001110101110 1001 HashMap 长度是默认的 16,length - 1 的结果。十进制 : 15;二进制 : 1111

把以上两个结果做与运算:101110001110101110 1001 & 1111 = 1001;1001 的十进制 = 9, 所以 index=9。

HashMap 的默认长度为 16,是为了降低 hash 碰撞的几率

HashMap死循环问题导致CPU100%

参考视频

主要原因是JDK1.7的头插法导致的。 假设一个index位置有一个长度为3的链表,从头到尾分别是:A->B->C,假设t1和t2线程在执行的时候,发生了扩容,t1线程先执行,扩容到新的indx时的顺序为C->B->A(头插法),而t2线程此时并没有感知到,此时A依然指向B,那么会发生循环链表的情况,此时在线程t2中的get操作可能会发生死循环。

这个问题在JDK1.8中已经改为尾插法。

ConcurrentHashMap

面试官:说说ConcurrentHashMap底层实现原理?

在JDK1.7中的实现

  • JDK1.7中基于数组+链表实现。
  • 数组分为:大数组Segment 和小数组HashEntry图片
  • JDK1.7中put的实现
final V put(K key, int hash, V value, boolean onlyIfAbsent) {  
    // 在往该 Segment 写入前,先确保获取到锁  
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);   
    V oldValue;  
    try {  
        // Segment 内部数组  
        HashEntry<K,V>[] tab = table;  
        int index = (tab.length - 1) & hash;  
        HashEntry<K,V> first = entryAt(tab, index);  
        for (HashEntry<K,V> e = first;;) {  
            if (e != null) {  
                K k;  
                // 更新已有值...  
            }  
            else {  
                // 放置 HashEntry 到特定位置,如果超过阈值则进行 rehash  
                // 忽略其他代码...  
            }  
        }  
    } finally {  
        // 释放锁  
        unlock();  
    }  
    return oldValue;  
}
  • Segment本身是基于ReentrantLock实现的加锁和释放锁的操作,能保证多个线程访问ConcurrentHashMap时,同一时间只能有一个线程能操作相应的节点。
  • ConcurrentHashMap的线程安全是基于Segment加锁实现的,我们将之称为分段锁。 图片

JDK1.8中的实现

  • JDK1.8基于数组+链表/红黑树实现 图片
  • 链表升级为红黑树的规则:当链表长度>8,并且数组的长度>64时,链表会升级为红黑树结构
  • JDK1.8中使用CAS+volatile/Synchroniezed的方式来保证线程安全
final V putVal(K key, V value, boolean onlyIfAbsent) if (key == null || value == null) throw new NullPointerException();  
    int hash = spread(key.hashCode());  
    int binCount = 0;  
    for (Node<K,V>[] tab = table;;) {  
        Node<K,V> f; int n, i, fh; K fk; V fv;  
        if (tab == null || (n = tab.length) == 0)  
            tab = initTable();  
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 节点为空  
            // 利用 CAS 去进行无锁线程安全操作,如果 bin 是空的  
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))  
                break;   
        }  
        else if ((fh = f.hash) == MOVED)  
            tab = helpTransfer(tab, f);  
        else if (onlyIfAbsent  
                 && fh == hash  
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))  
                 && (fv = f.val) != null)  
            return fv;  
        else {  
            V oldVal = null;  
            synchronized (f) {  
                   // 细粒度的同步修改操作...   
                }  
            }  
            // 如果超过阈值,升级为红黑树  
            if (binCount != 0) {  
                if (binCount >= TREEIFY_THRESHOLD)  
                    treeifyBin(tab, i);  
                if (oldVal != null)  
                    return oldVal;  
                break;  
            }  
        }  
    }  
    addCount(1L, binCount);  
    return null;  
}
  1. 添加元素时,首先会判断容器是否为空,为空则使用volatile+CAS来初始化
  2. 容器不为空,则根据存储的元素计算该位置是否为空,为空的话则利用CAS在该位置设置该节点。
  3. 如果不为空则使用Synchronized加锁,遍历桶中的数据,根据要put的key/value是否已存在来决定是替换还是新增

对比

  • ConcurrentHashMap JDK1.7中加锁是通过给 Segment 添加 ReentrantLock 锁来实现线程安全的,JDK 1.8 中使用的是数组+链表/红黑树的方式实现的,它是通过 CAS 或 synchronized 来实现线程安全的,并且它的锁粒度更小,查询性能也更高。
  • JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度,

String类为什么设置为final

参考:String类为什么设置为final

  1. 支持字符串常量池,JVM通过字符串常量池复用相同值的String对象,减少内存开销。
String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2); // 输出 true,因为字符串池中已经存在了 "Hello"

  1. 提高安全性,数据库的用户名和密码都是字符串,如果字符串可变,被改变字符串指向的对象,造成安全漏洞
  2. 由于字符串的不可变性,所以是多线程安全的。
  3. 可以避免继承破坏行为,防止子类覆盖关键方法,子类可能重写equals()、hashCode()等待方法,可能会导致依赖String类的行为异常(如String类型作为key的HashMap)

介绍下Java反射

1.反射基本概念

允许程序在运行时动态地获取类的元信息(如类名、方法、字段),并操作类或对象(如创建实例、调用方法、访问私有成员)。

  • 实现框架的灵活性(Spring的依赖注入)
  • 是动态代理、序列化等场景的基础

2.反射核心类与作用

类名作用
Class表示类或接口的元信息,是反射的入口类
Constructor表示类的构造方法,用于创建对象实例
Method表示类的方法,通过反射调用方法
Field表示类的字段(成员变量),可读取或修改字段值
Modifier解析类、方法、字段的修饰符(public、static等)
Array动态创建和操作数组

3.核心方法详解

3.1获取Class实例的3种方式

  • Class.forName("全限定类名")
//动态加载类
Class<?> clazz= Class.forName("java.lang.String");
  • 对象实例.getClass()
//通过对象实例获取
String str = "hello";
Clazz<?> clazz = str.getClass();
  • 类名.class
//直接通过类字面量获取
Class<String> clazz = String.class;

3.2 操作构造方法获取实例(Constructor)

  • 获取构造方法
// 获取所有 public 构造方法
Constructor<?>[] constructors = clazz.getConstructors();
// 获取指定参数类型的构造方法(包括私有)
Constructor<?> constructor = clazz.getDeclaredConstructor(String.class);
  • 通过构造方法创建对象实例
// 调用 public 构造方法
Object obj = constructor.newInstance("参数");

//强制访问private 构造方法
constructor.setAccessible(true);//突破私有权限限制
Object obj = constructor.newInstance();

3.3操作方法(Method)

  • 获取方法
//获取所有 public 方法(包括父类)
Method[] methods = clazz.getMethods();

//获取本类声明的方法(包括私有)
Method method = clazz.getDeclaredMethod("方法名",参数类型.class);
  • 调用方法
Object result = method.invoke(obj,"参数"); //obj:对象实例

3.4操作字段

  • 获取字段
//获取所有public字段
Field[] fields = clazz.getFields();

//获取本类声明的字段(包括private)
Field field = clazz.getDeclaredField("字段名");
  • 读取/修改字段值
filed.setAccessible(true);
Object value = field.get(obj);
field.set(obj,"新值");

反射的使用场景

  • 动态加载类:如JDBC驱动加载Class.forName("com.mysql.jdbc.Driver");
  • 框架设计:Spring通过反射创建Bean并注入依赖
  • 单元测试:访问私有方法或者字段进行测试。
  • 序列化/反序列化:JSON库(如Jackson)通过反射解析对象结构。

反射注意事项

问题注意事项
性能开销反射比直接调用慢,必要时缓存Class、Method对象
安全限制通过SecurityManager限制反射权限,防止恶意访问
破坏封装性谨慎使用setAccessible(true),避免破坏代码健壮性
兼容性问题反射可能因JDK版本升级失效(如内部类名变化)