Java基础

Posted by 佳运 Blog on April 12, 2025

Java基础

JavaSE

深拷贝、浅拷贝和引用拷贝

浅拷贝:

  • 浅拷贝是指创建一个新对象,但新对象中的引用类型字段仍然指向原对象中引用类型的内存地址。换句话说,浅拷贝只复制了对象本身,而没有复制对象内部的引用类型数据。修改新对象中的引用类型数据会影响原对象。
  • 浅拷贝可以使用 Object 类的 clone() 方法,也可以使用实现 Cloneable 接口并重写 clone() 的方法。
  • 浅拷贝适用于当对象内部的引用类型数据不需要独立复制的情况。

深拷贝:

  • 深拷贝是指创建一个新对象,并且递归地复制对象内部的所有引用类型数据。换句话说,深拷贝不仅复制了对象本身,还复制了对象内部的所有引用类型数据。修改新对象中的引用类型数据不会影响原对象。
  • 深拷贝可以手动对引用类型字段进行递归拷贝,也可以使用序列化(Serialization)的方式将对象序列化为字节流,再反序列化为新对象
  • 深拷贝适用于当对象内部的引用类型数据需要完全独立的情况。

引用拷贝:

在 Java 中,引用拷贝(Reference Copy)是指将一个对象的引用赋值给另一个变量。换句话说,两个变量指向同一个对象,而不是创建一个新的对象。这种操作不会复制对象本身,而是复制对象的引用地址。

*****补充:

  • 深拷贝和浅拷贝都是新创建了一个对象,而引用拷贝没有创建新对象。
  • 序列化是把堆内存中的 Java 对象数据,通过某种方式把对象存储到磁盘文件中或者传递给其他网络节点(在网络上传输)。而反序列化则是把磁盘文件中的对象数据或者把网络节点上的对象数据,恢复成Java对象模型的过程。
  • 序列化是将对象写到流中便于传输,而反序列化则是把对象从流中读取出来。这里写到流中的对象则是原始对象的一个拷贝,因为原始对象还存在 JVM 中,所以我们可以利用对象的序列化产生克隆对象,然后通过反序列化获取这个对象。

String、StringBuffer、StringBuilder:

特性 String StringBuffer StringBuilder
可变性 不可变 可变 可变
线程安全 线程安全(不可变性保证) 线程安全(方法加同步锁) 非线程安全
性能 频繁修改时性能低 性能较高,但低于 StringBuilder 单线程环境下性能最高
使用场景 字符串内容不经常变化 多线程环境下频繁修改字符串 单线程环境下频繁修改字符串

StringBuffer的特点:

  • 第一个是它具有可变性,我们可以在原有对象上直接修改字符串内容,而无需创建新的对象。
  • 第二个它是线程安全的,StringBuffer 的所有方法都通过 synchronized 关键字修饰,因此它是线程安全的。 在多线程环境下,多个线程可以同时操作同一个 StringBuffer 对象,而不会引发数据竞争或不一致问题。
  • 第三个是性能相对较好,StringBuffer 内部使用一个可扩容的字符数组来存储数据,当容量不足时会自动扩展。相比于 String 的不可变性(每次修改都会生成新对象),StringBuffer 在频繁修改字符串时性能更高。而相比于非线程安全的 StringBuilder ,性能略低。
  • 第四个是包含丰富的 API,比如:append():追加内容到字符串末尾。 insert():在指定位置插入内容。delete():删除指定范围的内容。 reverse():反转字符串内容。 toString():将 StringBuffer 转换为 String。

Java集合

单列集合(以 Collection 为核心接口):

  • 第一种是 List,用于存储有序且可重复的元素,比如 ArrayList 基于动态数组,访问快但插入删除慢;LinkedList 基于双向链表,插入删除快但访问慢;Vector 是线程安全的老版本实现。
  • 第二种是 Set,用于存储无序且不可重复的元素,比如 HashSet 基于哈希表,查找快但无序;LinkedHashSet 保留插入顺序;TreeSet 基于红黑树,按顺序存储。
  • 第三种是 Queue,用于队列操作,比如 PriorityQueue 基于堆实现按优先级处理,ArrayDeque 是双端队列支持栈和队列操作。

双列集合(以 Map 为核心接口):

HashMap 基于哈希表,键无序且查找快;LinkedHashMap 保留插入顺序;TreeMap 基于红黑树,按键的自然顺序排序;Hashtable 是线程安全的老版本实现。

ArrayList和LinkedList的区别

  • 是否保证线程安全: ArrayListLinkedList 都是不同步的,也就是不保证线程安全;

  • 底层数据结构: ArrayList 底层使用的是 Object 数组LinkedList 底层使用的是 双向链表 数据结构
  • 插入和删除是否受元素位置的影响:
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    • LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)addFirst(E e)addLast(E e)removeFirst()removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element)remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。
  • 是否支持快速随机访问:LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  • 内存空间占用:ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

ArrayList的扩容方式

以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10int newCapacity = oldCapacity + (oldCapacity >> 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)!

HashMap

HashMap 的底层实现

  • jdk1.8之前:

​ 使用 数组加链表 结合在一起使用,也就是链表散列。HashMap 通过 key 的 hashcode 经过扰动函数(无符号右移 >>>)处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

  • jdk1.8之后:

​ 使用数组加链表加红黑树实现。当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容resize() ),而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

扩容机制如下:

  • 在 HashMap 中有一个阈值的概念,HashMap 在元素数量超过阈值时,就会触发扩容,例如,如果我们创建一个大小为 16 的 HashMap,那么默认的阈值为 16 * 0.75 = 12。这意味着一旦 HashMap 中的元素数量超过 12,就会触发扩容。
  • 扩容时HashMap 会先新建一个数组,新数组的大小是老数组的两倍,然后会将 HashMap 内的元素重新哈希,映射并搬运到新的数组中。

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

  • 位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时,hash % length 等价于 hash & (length - 1)
  • 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。
  • 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。

为什么不直接用hashCode,而是要重新设计一套hash计算流程

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

当我们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)的树结构来提升碰撞下的性能

有100个元素,HashMap设计成多大不会发生扩容

HashMap要使用2的次幂才能保证最佳性能,能放100个元素的最小二次幂数是128,但当数组长度为128时,当数组长度达到128*0.75=96时会触发HashMap的自动扩容机制,扩容的过程比较影响性能,所以初始化设置为256最佳。

JDK1.8之前 HashMap 多线程导致死锁循环问题

JDK1.7 及之前版本HashMap多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。

为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap

ConCurrentHashMap

ConcurrentHashMap和HashTable的区别:

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

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

  • 实现线程安全的方式(重要):
  • 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。

  • 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;

  • Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

HashTable:

JDK1.7 的 ConcurrentHashMap

JDK1.8及以后 的 ConcurrentHashMap

其中

  • CAS:在判断数组中当前位置为null的时候,使用CAS来把这个新的Node写入数组中对应的位置
  • synchronized :当数组中的指定位置不为空时,通过加锁来添加这个节点进入数组(链表<8)或者是红黑树(链表>=8)

BlockingQueue

BlockingQueue (阻塞队列)是一个接口,继承自 QueueBlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。

Java 中常用的阻塞队列实现类有以下几种:

  1. ArrayBlockingQueue使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。
  2. LinkedBlockingQueue使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。
  3. PriorityBlockingQueue支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。
  4. SynchronousQueue同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。
  5. DelayQueue延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。