JAVA面试03

本文详细介绍了Java中的ArrayList、LinkedList、HashMap和ConcurrentHashMap等数据结构,包括它们的内部实现、操作方法和性能优化。特别关注了内存管理和并发控制,以及如何避免ThreadLocal引发的内存泄漏问题。
摘要由CSDN通过智能技术生成

ArrayList

默认大小为空实例数组,在第一次初始化的时候设置初始容量为 10,扩容时扩容到原数组大小+原数组大小/2,新建一个新数组,然后使用 Arrays.copyOf()方法进行复制原数组元素

get

先校验索引是否越界,然后根据 index 返回对应位置的元素

set

先校验索引是否越界,用传入的 element 替换 index 位置的元素,返回原数据

add

校验添加元素后是否需要扩容,需要则进行扩容,然后在数组的尾部添加元素

remove

先校验索引是否越界,计算需要移动元素的个数,如果需要移动,则将 index

+1 位置后的元素向左移动一个位置,然后将 size-1 位置的元素赋值为 null

LinkedList

有字段 size(节点数量)、first(头节点)、last(尾节点)

node

获取在特定位置的节点,先将 index 与 size/2 进行比较,小的话从头节点开始遍历,大的话从尾节点开始遍历,返回目标节点

linkLast

将元素添加到尾部,获取原 last 节点,将新节点作为新的 last 节点,然后与原 last 节点构成关联关

系,size+1

get

先校验索引是否越界(与 size 进行比较),然后调用 node()方法获取目标节点值

set

先校验索引是否越界,然后调用 node()方法,找到目标节点,用传入的 element 替换目标节点的值,返回原值

add

校验索引是否越界,如果索引为 size,将 element 插入链表尾部,否则调用 node()方法获取

index 位置的节点,原 index 节点前驱节点的 next 指向新节点,原 index 节点的 prev 指向新节点,新节点的 next 指向原 index 节点

remove

先校验索引是否越界,然后调用 node()方法获取 index 位置上的节点,调用 unlink()方法移除链表上的目标节点

unlink

更改目标节点的前结点和后节点的指针,对于头节点和尾节点需要没有前节点或后节点,然后将目标节点的 prev、next 和值设置为 null

HashMap

table 数组存储 Node(链表节点,继承自 Entry),默认容量为 16;默认负载因子为 0.75;链表节点转红黑树节点 9 个节点开始转;红黑树转链表节点 6 个节点开始转;转红黑树时,table 最小长度为 64

hash

计算 key 的新 hash 值,拿到 key 的原 hashCode 的值,将原 hashCode 和原 hashCode 的高 16位进行异或,得到新的 hash 值,将高位参与运算,是为了在数组长度较小时降低 hash 冲突的概率

get

先对 table 进行校验,table 不为空,长度大于 0,并且 key 在 table 中的索引位置(将 key 的hash 值与 table 长度-1 进行位与运算)的节点不为空,否则返回 null。

获取 table 的索引位置的首节点,判断该头节点的 key 与传入的 key 是否一致(hash 值相等,值也相等),一致则表示该头节点即为目标节点,返回该头节点。

如果头节点不是目标节点,并且头节点的 next 不为空则继续遍历,如果是红黑树节点,则调用红黑树的查找方法获取目标节点;如果不是红黑树节点,则向下遍历链表,判断后续节点的 key 与传入的 key 是否一致,一致则返回该节点,最后找不到则返回 null

put

校验 table 是否为空或长度为零,是的话则调用 resize 方法进行初始化。

根据 hash 值计算索引位置,如果 table 中该索引位置为空,则新建一个节点放入该位置即可。

不为空则进行查找,判断该索引位置的首节点的 key 值是否与传入的一致,一致则获取该节点。

不一致则继续遍历,如果是红黑树节点则调用红黑树的添加方法,存在 key 一致的则获取旧节点,

不存在则新建节点,然后进行红黑树的插入平衡调整。

如果不是红黑树节点,则向下遍历链表,如果没有找到一致的,则在链表的最后添加一个新节点,

判断链表节点个数是否超过 8 个,超过则将链表转为红黑树。

判断旧节点是否为 null,不为 null 则表示是替换旧值,返回旧值。为 null 则判断插入节点后 table节点个数是否超过阈值(table 长度*加载因子),超过则进行扩容

resize

table 扩容,如果旧表容量为零,阈值为零,则表示需要初始化,把默认值设置进去。旧表不为空则进行扩容,将新表容量扩容为旧表容量的 2 倍。

遍历旧表的节点,如果头节点不为 null,头节点的 next 为 null,则表示只有这一个节点,计算节点

在新表的索引位置,直接将该节点放入新表索引位置。

如果头节点的 next 不为为 null,判断该节点是否为红黑树节点,是的话进行红黑树的重 hash 分布,也是分成两个新树,根据 key 的 hash 与原容量进行位与,然后再判断是否需要将红黑树节点转为链表节点

如果节点是链表节点,则定义两个新链表,一个表示节点在原索引位置,一个表示节点在新索引位置(原索引位置+原容量)。将 key 的 hash 与原容量进行位与,为 0 和为 1 放置不同的新链表里,将两个新链表的头节点放在新 table 中相应的位置

使用红黑树

链表的查找性能为 O(n),红黑树的 O(logn),当数据量大时,可以提高性能。

加载因子是 0.75

基于时间和空间的权衡结果,加载因子过高,例如 1,虽然减少了空间的开销,提高了空间利用率,但是增加了查询时间成本;加载因子过低,例如 0.5,虽然减少查询时间成本,但是空间利用率低,提高了扩容操作的次数

为什么是 8 个节点转

红黑树节点的大小约为链表的 2 倍,在节点较少时,红黑树的查找性能优势不太明显,付出两倍空间不值得。使用随机的哈希码,节点在 hash 桶中的频率遵循泊松分布,节点数为 8 个的概率相当低,到 8 个节点时,红黑树的性能优势也会开始展现出来

为什么转回链表时为 6 个

如果都设置为 8 个,当节点数在 8 徘徊时,就会频繁的进行红黑树和链表的转换,造成性能损耗线程不安全的点put 时是直接新建的节点,如果多个线程同时执行到该处时,则最初 put 的节点都会被最后 put 的覆盖

ConcurrentHashMap

1.7

由 Segment 数组和 HashEntry 数据组成。Segment 继承了 ReentrantLock,在 put 时会先进行

加锁操作,每一个 Segment 相当于一个 HashMap。

put

进行两次 Hash 去定位数据的存储位置。根据 key 的 hashCode 值定位到哪个 segment,如果该

Segment 还没有初始化,则通过 CAS 操作进行赋值;然后进行第二次 hash 操作,找到相应的

HashEntry 的位置,先通过 tryLock()方法尝试去获取锁,如果成功就直接插入相应的位置

1.8

采用 CAS + synchronized 保证线程安全,synchronized 锁对象为 table 在当前索引位置的头节点

get

计算 hash 值,定位到该 table 索引位置,如果首节点符合就返回

如果当前 table 正在扩容,会调用标志正在扩容节点 ForwardingNode 的 find 方法,查找该节点,匹配就返回

以上都不符合,就往下遍历节点,匹配就返回,都不匹配返回 null

put

先进行无限循环,如果 table 为空则进行初始化表。初始化时,使用 CAS 将扩容状态设置为-1,保证只有一个线程可以设置成功并进行初始化。然后获取 key 的 hash 值在 table 表中索引位置的头节点,如果头节点为 null,则使用 CAS 新建一个头节点。

如果在进行扩容,则帮助扩容,帮助从旧的 table 的元素复制到新的 table 中,调用多个工作线程一起帮助扩容,这样效率会更高

如果以上条件都不成立,则使用 synchronized 对头节点进行加锁,如果头节点为链表节点,则遍历链表,找到并修改 key 对应的节点,未找到则在链表末尾添加一个节点;

如果为红黑树节点,则从根节点进行遍历,更新或新增节点

如果链表的长度大于阈值 8,则进行红黑树的转换,如果 table 的数量小于 64,就扩容至原来的一倍,不转红黑树了

最后统计 size,检查是否需要扩容

扩容过程

构建一个 nextTable 对象,其容量为原来容量的两倍,使用 ForwardingNode,ForwardingNode

的作用就是支持扩容操作,将已处理的节点和空节点置为 ForwardingNode。遍历旧 table 中的所

有节点,如果遍历到了 ForwardingNode 节点,意味着该节点已经处理过了;如果不是,则使用

synchronized 对头节点进行加锁,遍历原链表,分成两个链表插入到新 table 中,在旧 table 的 i

索引位置插入 ForwardingNode 表示该节点已经处理过了

1.8 中的改进

1、1.8 中锁粒度降低了,1.7 中锁的粒度是基于 Segment 的,1.8 中锁的粒度是基于 Node 数组中

的首节点

2、1.8 中的数据结构变得更加简单,操作更加清晰流畅,不需要分段锁的概念,也就不需要Segment 这种数据结构了

3、使用红黑树来优化链表,遍历效率更快

为什么使用 synchronized 来代替 ReentrantLock

1、锁粒度降低了,在低粒度加锁时,synchronize 性能并不比 ReentrantLock 差,在粗粒度加锁中,ReentrantLock 可能通过 Condition 来控制各个低粒度的边界,更加灵活,而在低粒度中,Condition 的优势就没有了

2、基于 JVM 的 synchronize 优化空间更大,使用内嵌的关键字比使用 API 更加自然

3、在大量的数据操作下,对于 JVM 的内存压力,基于 API 的 ReentrantLock 会开销更多的内存,虽然不是瓶颈,但也是一个选择依据AQS 与 ReentrantLock

AbstractQueuedSynchronizer(AQS)是一个抽象类,内部通过一个 int 类型的成员变量 state 来控制同步状态,当 state=0 时,表示没有任何线程占有共享资源的锁,当 state>0 时,表示已经有线程正在使用共享变量,其他线程必须加入同步队列进行等待。

通过成员变量 exclusiveOwnerThread(独占锁线程)标识当前获取锁的线程

AQS 内部通过内部类 Node 构成 FIFO 的同步队列来完成线程获取锁的排队工作

ReentrantLock 中的内部类 Sync 继承了 AQS,实现了 AQS 的 tryRelease 方法,内部类

NonfairSync 和 FairSync 继承了 Sync,实现了 AQS 的 tryAcquire 方法

以下是非公平锁的实现,公平锁与非公平锁的区别在于,公平锁在使用 CAS 尝试设置 state 同步状态时,先判断同步队列是否存在节点,如果存在则将当前线程封装成 Node 节点加入到同步队列尾部。非公平锁是无论同步队列是否存在节点,都直接尝试获取锁,获取成功则访问共享资源。

tryLock(ReentrantLock 里)

调用 nonfairTryAcquire 方法。判断同步状态 state 是否为 0,为 0 则执行 CAS 操作将 state 从 0设置为 1,获取成功则设置独占锁线程为当前线程,返回 true。获取失败则判断独占锁线程是否为当前线程,是的话则将 state 加 1,返回 true。不是当前线程的话,则返回 false,表示尝试加锁失败

lock(ReentrantLock 里)

执行 CAS 将 state 从 0 设置为 1,设置成功代表获取锁成功,设置独占锁线程为当前线程。设置失败调用 acquire 方法(AQS 里)来获取锁,调用 tryAcquire(ReentrantLock 里)方法再次尝试获取锁,tryAcquire 方法同样调用 nonfairTryAcquire 方法。

获取失败则新建一个独占模式的节点,添加到同步队列的尾部,添加时同样使用 CAS 将尾节点修改为新建的节点。添加完毕后判断该节点是否可以获取锁,即判断该节点的前驱节点是否为头节点,前驱节点为头节点则再次调用 tryAcquire 获取锁。前驱节点不为头节点则判断是否需要将 node 节点的线程阻塞,即判断前驱节点的等待状态为 SIGNAL(等待被唤醒),是的话则将当前节点挂起,不是的把将前驱节点的状态使用 CAS 设置为 SIGNAL,等下次循环再将当前节点挂起

unlock(ReentrantLock 里)

调用 release 方法(AQS 里)释放锁,调用 tryRelease(ReentrantLock 里)进行尝试释放锁,如果当前线程不是独占锁线程则抛出异常,将 state 减一,减 1 之后如果 state 为 0,将独占锁线程设置为 null,表示已释放锁。释放锁之后,调用 unparkSuccessor(AQS 里)唤醒同步队列中头节点的后继节点线程

ThreadLocal

有一个 ThreadLocalMap 内部类,是一个自定义哈希映射,仅用于维护线程本地变量值,内部类中

有一个 Entry 数组,Entry 的 key 为 ThreadLocal,value 为 ThreadLocal 对应的值。每一个线程

都有一个 ThreadLocalMap 类型的 threadLocals

set

获取当前线程的 threadLocals 变量,如果 threadLocals 为空,则创建一个新的

ThreadLocalMap,Entry 数组的长度为 16,新建一个 Entry 放入该 ThreadLocalMap,调用 set

方法将 ThreadLocal 和传入的 value 作为该 Entry 的 key 和 value。如果 threadLocals 不为空则调用 ThreadLocalMap 的 set 方法将传入的 value 值设置进去。

ThreadLocalMap 的 set 方法:根据 ThreadLocal 的 threadLocalHashCode 变量得出在 Entry 数组中的索引,从索引处向后遍历,当遍历到的 Entry 为 null 时结束,获取该 Entry,如果该 Entry的 key 和传入的 key 相等,即 hashCode 值相等,则用传入的 value 替换原来的 value,并返回。

遍历过程中清除 key 为 null 的 Entry,如果最后没找到 key 相等的 Entry,则在上述遍历到 Entry

为空的位置上新建一个 Entry 放入进去。

get

获取当前线程的 threadLocals 变量,根据 ThreadLocal 的 threadLocalHashCode 变量得出在

Entry 数组中的索引,获取 Entry,如果该 Entry 的 key 和传入的 key 相等,则返回 value。如果不相等,则向后遍历,找到相等的,返回 value,未找到则返回 null。

内存泄漏问题

ThreadLocalMap 使用 ThreadLocal 的弱引用作为 Entry 的 key,如果一个 ThreadLocal 没有外部强引用来引用它,下一次系统 GC 时,这个 ThreadLocal 必然会被回收,ThreadLocalMap 中就会出现 key 为 null 的 Entry,如果当前线程一直在运行,并且一直不执行 get、set、remove 方法,这些 key 为 null 的 Entry 会存在一条强引用链:

Thread->ThreadLocalMap->Entry->value,导致这些 key 为 null 的 Entry 的 value 永远无法被回收,造成内存泄漏

如何避免内存泄漏问题

每次使用完 ThreadLocal 后,手动调用 remove 方法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值