余声-个人博客


  • 首页

  • 分类

  • 归档

  • 标签

聊聊常见set类都有哪几种

发表于 2024-10-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

👌聊聊常见set类都有哪几种?

题目详细答案

在Java中,Set是一种不包含重复元素的集合,它继承自Collection接口。用于存储无序(存入和取出的顺序不一定相同)元素,值不能重复。对象的相等性本质是对象 hashCode 值(java 是依据对象的内存地址计算出的此序号)判断的,如果想要让两个不同的对象视为相等的,就必须覆盖 Object 的 hashCode 方法和 equals 方法。Java中常见的Set实现类主要有三个:HashSet、LinkedHashSet和TreeSet。

HashSet

HashSet是Set接口的一个实现类,它基于哈希表实现,具有快速的插入、删除和查找操作。

HashSet不保证元素的迭代顺序,允许null元素的存在,但只能有一个null元素,不是线程安全的,如果多个线程同时访问并修改HashSet,则需要外部同步。

当存储自定义对象时,需要重写对象的hashCode()和equals()方法,以确保对象的唯一性。

LinkedHashSet

LinkedHashSet是HashSet的子类,它基于哈希表和双向链表实现,继承与 HashSet、又基于 LinkedHashMap 来实现的。可以维护元素的插入顺序。相比于 HashSet 增加了顺序性。

其主要是将元素按照插入的顺序进行迭代,同时继承了HashSet的所有特性。因此当需要保持元素插入顺序时,可以选择使用LinkedHashSet。

TreeSet

TreeSet是Set接口的另一个实现类,它基于红黑树实现,可以对元素进行自然排序或自定义排序。

可以实现元素按照升序进行排序(自然排序或自定义排序)。不过它不允许null元素的存在。treeset 同样是线程不安全的。

当存储自定义对象时,如果想要进行排序,需要实现Comparable接口并重写compareTo()方法,或提供自定义的Comparator对象来进行排序。

适用场景

HashSet:适用于需要快速查找的场景,不保证元素的顺序。

LinkedHashSet:适用于需要保持元素插入顺序的场景。

TreeSet:适用于需要元素排序的场景。

说一下java8实现的concurrentHashMap?

发表于 2024-10-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

👌说一下java8实现的concurrentHashMap?

题目详细答案

Java 8 对ConcurrentHashMap进行了重新设计,取消了分段锁的机制,改用更细粒度的锁和无锁操作来提高并发性能。

数据结构

Node:基本的链表节点,存储键值对和指向下一个节点的指针。

TreeNode:用于红黑树的节点,当链表长度超过一定阈值(默认是8)时,链表会转换为红黑树。

TreeBin:红黑树的容器,管理红黑树的操作。

ForwardingNode:在扩容过程中用于指示节点已经被移动。

主要操作

put 操作:通过 CAS 操作和细粒度的锁来实现高效的并发插入和更新。

get 操作:使用无锁的方式进行查找,性能更高。

扩容:通过逐步迁移节点和协作扩容机制,提高扩容效率。

细粒度的并发控制

Java 8 中的ConcurrentHashMap采用了更细粒度的并发控制,主要通过以下方式实现:

CAS 操作:使用 CAS 操作(Compare-And-Swap)进行无锁插入和更新,减少锁竞争。

synchronized 块:在必要时对单个桶(bin)进行加锁,而不是整个段,从而进一步提高并发性。

红黑树:当链表长度超过阈值时,转换为红黑树,降低查找时间复杂度,从 O(n) 降低到 O(log n)。

Java 8 相比 Java 7 的好处

更高的并发性:Java 7 使用段级别的锁,而 Java 8 使用更细粒度的锁和无锁操作,减少了锁竞争,提高了并发性。

更好的性能:Java 8 中的get操作是无锁的,性能更高。put操作使用 CAS 和细粒度的锁,提高了插入和更新的性能。

更高效的扩容:Java 8 通过逐步迁移节点和协作扩容机制,提高了扩容效率,减少了扩容过程中对性能的影响。

更高效的查找:当链表长度超过阈值时,转换为红黑树,降低了查找时间复杂度。

/kfgmeauv8z37pxgd>

重新调整HashMap大小存在什么问题吗?

发表于 2024-10-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

👌重新调整HashMap大小存在什么问题吗?

题目详细答案

性能影响

时间复杂度:扩容是一个相对昂贵的操作,因为它需要重新计算所有现有键值对的哈希值,并将它们重新分配到新的桶数组中。这个过程的时间复杂度为 (O(n)),其中 (n) 是哈希表中元素的数量。因此,在扩容期间,可能会导致性能的短暂下降,尤其是在插入大量数据时。

阻塞操作:在单线程环境中,扩容会阻塞其他操作(如查找、插入、删除),直到扩容完成。在多线程环境中,如果没有适当的同步机制,扩容可能会导致数据不一致或其他并发问题。

内存使用

临时内存消耗:扩容期间,HashMap需要分配一个新的桶数组,同时保留旧的桶数组,直到重新哈希完成。这会导致临时的内存消耗增加。如果哈希表非常大,可能会导致内存不足的问题。

内存碎片:频繁的扩容和缩容可能导致内存碎片化,降低内存利用效率。

并发问题

线程安全:默认的HashMap不是线程安全的。在多线程环境中,如果一个线程在进行扩容操作,而另一个线程在进行插入或删除操作,可能会导致数据不一致或程序崩溃。为了解决这个问题,可以使用ConcurrentHashMap或在外部进行同步。

扩容期间的数据一致性:在扩容过程中,如果有其他线程在进行读写操作,可能会导致数据不一致。因此,在多线程环境中,必须确保扩容操作是原子的,或者使用并发安全的数据结构。

重新哈希的成本

哈希函数的复杂性:重新哈希所有键值对需要调用哈希函数。如果哈希函数较为复杂,重新哈希的成本也会增加。

哈希冲突的处理:在扩容过程中,哈希冲突的处理(如链地址法中的链表或红黑树)也会增加额外的开销。

应用层面的影响

实时性要求:在某些实时性要求较高的应用中,扩容操作可能导致短暂的性能下降,影响系统的响应时间。

数据一致性要求:在某些应用中,数据的一致性要求较高,扩容过程中可能会导致短暂的数据不一致,需要额外的机制来保证一致性。

解决方案和优化

  1. 预估初始容量:如果可以预估数据量,尽量在创建HashMap时设置合适的初始容量,减少扩容次数。
  2. 使用并发数据结构:在多线程环境中,使用ConcurrentHashMap代替HashMap,它采用了分段锁机制,减少了扩容带来的并发问题。
  3. 动态调整负载因子:根据应用需求,动态调整负载因子以适应数据量的变化。

/gifnny9n0u67fo68>

阻塞队列原理?

发表于 2024-10-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

👌阻塞队列原理?

题目详细答案

阻塞队列是一种线程安全的队列,它在插入和删除操作上可以阻塞线程,以实现生产者-消费者模式等并发编程需求。阻塞队列的核心原理包括锁机制和条件变量。

基本原理

锁机制

阻塞队列使用锁(如ReentrantLock)来确保线程安全。锁保证了同一时间只有一个线程可以执行插入或删除操作,从而避免并发问题。

条件变量

阻塞队列使用条件变量(Condition)来管理线程的等待和通知。条件变量是与锁关联的,可以在特定条件下阻塞线程并在条件满足时唤醒线程。例如,notEmpty和notFull是常见的条件变量,分别用于表示队列是否为空和是否已满。

等待和通知机制:

当线程试图执行插入操作而队列已满时,它会在notFull条件变量上等待,直到队列中有空闲空间。

当线程试图执行删除操作而队列为空时,它会在notEmpty条件变量上等待,直到队列中有可用的元素。

当插入或删除操作成功后,相应的条件变量会被通知(唤醒),以便其他等待的线程可以继续执行。

具体实现 Demo

LinkedBlockingQueue是一个基于链表实现的可选有界阻塞队列。它的基本原理如下:

内部结构:使用一个链表来存储元素。使用两个锁:takeLock和putLock,分别用于控制删除和插入操作。使用两个条件变量:notEmpty和notFull,分别用于表示队列是否为空和是否已满。

插入操作(put):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
while (count.get() == capacity) {
notFull.await();
}
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity) {
notFull.signal();
}
} finally {
putLock.unlock();
}
if (c == 0) {
signalNotEmpty();
}
}

删除操作(take):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1) {
notEmpty.signal();
}
} finally {
takeLock.unlock();
}
if (c == capacity) {
signalNotFull();
}
return x;
}

等待和通知:

在插入操作中,如果队列已满,线程会在notFull条件变量上等待。

在删除操作中,如果队列为空,线程会在notEmpty条件变量上等待。

插入或删除操作成功后,会相应地通知等待的线程。

/rizcvgg0mxqp9iv7>

HashMap底层

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 java
字数统计 | 阅读时长

HashMap是Java中一种非常重要的数据结构,它基于哈希表的原理,实现了对键值对(key-value pair)的快速存储和访问。下面详细解释HashMap的数据结构以及其在JDK 1.8中的变化。

HashMap的基本数据结构

在HashMap中,数据是通过“数组+链表”的方式存储的,这种结构也被称为“链表的数组”。具体来说:

  1. 数组:HashMap内部维护了一个Entry数组(在JDK 1.8及以后版本中,Entry被Node类替代,但功能相同),数组的每个元素都是一个桶(Bucket),桶中存放的是指向链表节点的引用。

  2. 链表:当发生哈希冲突(即不同的key通过哈希函数计算得到的下标相同)时,这些key-value对会被存储在同一桶中的链表中。链表中的每个节点都包含key、value、next等字段,用于存储数据和指向下一个节点的引用。

哈希函数与冲突解决

  1. 哈希函数:HashMap通过哈希函数(hash()方法)将key转换为数组的下标。这个哈希函数的目标是尽量均匀地分布元素,以减少哈希冲突的发生。

  2. 冲突解决:当发生哈希冲突时,HashMap采用链地址法(也称为拉链法)来解决。即在同一桶中维护一个链表,将冲突的元素依次添加到链表中。

JDK 1.8中的优化

在JDK 1.8中,HashMap进行了重要的优化,引入了红黑树来进一步减少哈希冲突对性能的影响。具体来说:

  1. 链表转红黑树:当桶中的链表长度超过一定阈值(默认为8)时,HashMap会将这个链表转换为红黑树。红黑树是一种平衡二叉搜索树,具有更高的查找效率(O(log n)),从而提高了HashMap的性能。

  2. 阈值调整:在JDK 1.8中,HashMap还引入了一个新的参数treeifyThreshold来控制链表转换为红黑树的阈值,以及untreeifyThreshold来控制红黑树转换为链表的阈值(当链表长度小于等于6时)。此外,还有一个minTreeifyCapacity参数,用于确保在链表转换为红黑树之前,HashMap的容量至少达到一定的阈值(默认为64),以避免频繁的树化操作。

总结

HashMap的数据结构是基于“数组+链表”的,通过哈希函数将key映射到数组的下标,并通过链表解决哈希冲突。在JDK 1.8中,HashMap引入了红黑树来优化性能,当链表长度超过一定阈值时,会将链表转换为红黑树以提高查找效率。这些优化使得HashMap在处理大量数据时具有更高的性能和更好的稳定性。

HashMap、Hashtable和ConcurrentHashMap

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 java
字数统计 | 阅读时长

在Java中,HashMap、Hashtable和ConcurrentHashMap都是用于存储键值对的数据结构,但它们在使用场景和内部实现上有显著的区别。下面是对这些区别的详细解释:

线程安全

  1. HashMap:

    • 非线程安全:HashMap没有考虑线程安全问题,如果在多线程环境下不加同步地使用HashMap,可能会导致数据不一致的问题,如死锁、数据丢失或覆盖。
  2. Hashtable:

    • 线程安全:Hashtable是线程安全的,它的所有方法都是同步的。这意味着在多线程环境中,同时访问和修改Hashtable不会导致数据不一致。但是,由于每个方法都使用同步,这可能导致在高并发环境下性能较低。
  3. ConcurrentHashMap:

    • 线程安全且高效:ConcurrentHashMap是为了解决在高并发环境下HashMap的性能问题和Hashtable的同步性能瓶颈而设计的。
    • JDK 1.7及之前:使用分段锁(Segment Lock)机制,将整个哈希表分为多个段(Segment),每个段本质上是一个小的哈希表,并且有自己的锁。这样,在高并发情况下,只要多个线程访问的是不同的段,就可以并行处理,提高了并发性能。
    • JDK 1.8及之后:取消了分段锁,而是采用了CAS(Compare-And-Swap)操作和synchronized关键字来实现更细粒度的锁。每个桶(Node)的锁控制更加精确,只有在必要时才锁定相关的桶,进一步提高了并发性能。

继承关系

  1. Hashtable:

    • 继承自java.util.Dictionary类,这是一个抽象类,提供了基本的键值对存储功能。Hashtable是最早的键值对存储实现之一,因此在Java早期版本中广泛使用。
  2. HashMap和ConcurrentHashMap:

    • 都继承自java.util.AbstractMap抽象类,并实现了java.util.Map接口。AbstractMap提供了Map接口的部分实现,以减少子类实现的工作量。
    • HashMap和ConcurrentHashMap都提供了Map接口的所有功能,但在实现上有所不同,以适应不同的使用场景。

使用场景

  • HashMap:适用于单线程环境或不需要考虑线程安全的情况,因为它提供了最好的性能。
  • Hashtable:适用于多线程环境,但需要同步(即线程安全)的键值对存储。然而,由于性能原因,现在通常推荐使用ConcurrentHashMap。
  • ConcurrentHashMap:适用于高并发环境,提供了线程安全的键值对存储,并且性能优于Hashtable。

总之,选择哪种Map实现取决于应用程序的具体需求,包括是否需要线程安全以及并发性能的要求。

List中的遍历方式

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 java
字数统计 | 阅读时长
  • 在Java中,遍历并修改一个List集合是一个常见的操作,但如果不小心处理,可能会遇到ConcurrentModificationException异常,这通常是因为在遍历过程中直接修改了集合的结构(如添加、删除元素)。下面是你提到的几种处理方案及其详细解释:
  1. 通过普通的for循环(不建议,可能会漏删):
    使用普通的for循环遍历List,并通过索引直接修改或删除元素。这种方法的问题是,如果删除元素后没有相应地调整索引(例如,连续删除元素时),可能会导致跳过某些元素的检查或索引越界异常。

  2. 通过普通的for循环进行倒序遍历:
    倒序遍历可以避免因删除元素而导致的索引调整问题,因为即使删除了元素,后面的元素索引也不会改变(相对于当前遍历方向)。这种方法可以有效避免漏删的问题。

  3. 使用迭代器循环:
    迭代器提供了一种安全的方式来遍历并修改集合。使用迭代器的remove方法可以安全地删除当前元素,而不会触发ConcurrentModificationException。注意,不能使用集合的remove方法,而应该使用迭代器的remove方法。

  4. 复制列表:
    创建一个原始列表的副本,遍历原始列表,同时在副本上进行删除操作。这种方法是fail-safe的,因为遍历和修改发生在不同的列表上。但这种方法相对复杂,且需要额外的内存来存储副本。此外,如果列表中的元素是复杂对象,可能需要正确地实现equals和hashCode方法以确保正确的删除。

  5. 使用并发安全的集合类:
    使用如CopyOnWriteArrayList这样的线程安全的集合类。这种集合类在修改时创建集合的副本,因此遍历和修改可以安全地进行,但这也可能导致在大量修改时性能下降,因为每次修改都需要复制整个集合。

  6. 使用Stream的过滤方法:
    Java 8引入的Stream API提供了一种声明性的方式来处理集合。通过filter方法,可以创建一个新的Stream,只包含满足特定条件的元素。然后,可以使用collect方法将结果收集到一个新的列表中。这种方法简单高效,因为它避免了在遍历过程中直接修改原始集合。

  7. 通过removeIf方法:
    Java 8的List接口引入了removeIf方法,它接受一个谓词(Predicate),并删除所有满足该谓词的元素。这是一个非常简洁和高效的方法,用于根据条件删除元素。

综上所述,每种方法都有其适用的场景和优缺点。在选择方法时,应考虑集合的大小、修改的频率、内存使用以及对性能的要求。对于大多数情况,使用removeIf或Stream的filter方法是推荐的做法,因为它们既简单又高效。

创建线程的方式

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长
  1. 继承Thread类创建线程:

    • 这种方式是通过创建一个新的类,该类继承自Thread类,并重写run方法。然后,通过创建该类的实例并调用其start方法来启动线程。
    • 优点:代码简单,易于理解。
    • 缺点:由于Java是单继承的,继承Thread类后就不能再继承其他类了,这在一定程度上限制了类的扩展性。
  2. 实现Runnable接口创建线程:

    • 这种方式是通过创建一个实现了Runnable接口的类,并实现其run方法。然后,将该类的实例作为参数传递给Thread类的构造器,创建Thread对象并调用其start方法来启动线程。
    • 优点:由于Java支持接口的多实现,因此这种方式更具灵活性,可以继承其他类并实现Runnable接口。
    • 缺点:相对于继承Thread类,代码稍显复杂。
  3. 通过Callable和FutureTask创建线程:

    • Callable接口类似于Runnable接口,但它可以返回结果并且可以抛出受检异常(checked exception)。
    • FutureTask是Future接口的一个实现,它包装了一个Callable对象,可以提交给Executor(如线程池)来执行。
    • 这种方式通常用于需要获取线程执行结果或处理受检异常的场景。
  4. 通过线程池创建线程:

    • 线程池是提前创建好一批线程,并保存在池中。当有任务需要执行时,从池中取出一个线程来执行任务。这种方式可以显著提高资源的利用率和性能。
    • Java中的ExecutorService接口提供了管理线程池的方法,如newFixedThreadPool、newCachedThreadPool等。
    • 优点:提高资源利用率、性能;简化线程管理。
    • 缺点:增加了代码的复杂性;需要合理配置线程池的大小以避免资源耗尽或性能下降。

关于Runnable和Callable的区别:

  • Runnable的run方法无返回值,而Callable的call方法有返回值。
  • Callable中可以抛出受检异常,而Runnable不可以。
  • Callable和Runnable都可以应用于executors(执行器),但Thread类只支持Runnable。

关于Future和FutureTask:

  • Future是一个接口,代表了一个异步执行的结果。它提供了检查执行是否完成、等待完成和获取执行结果的方法。
  • FutureTask是Future接口的一个实现,它实现了一个可以提交给Executor执行的任务,并且可以用来检查任务的执行状态和获取任务的执行结果。

最后,线程池是池化技术的一种典型实现,用于提高资源的利用率和性能。在编程中,线程池通常用于管理大量并发任务的执行。

并发笔记

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 java
字数统计 | 阅读时长

并发

synchronized

同一时刻只能一个线程执行代码块

可以修饰方法和代码块

如何实现可见性问题

  • 实现可见性的过程

    • 获取互斥锁

    • 清空本地代码,将主内存中的最新拷贝到本地内存

    • 将更改后共享变量值刷新到主内存

    • 释放互斥锁

如何实现同步

  • 都是使用mointorenter和monitorexit两个JVM指令实现

  • 什么是管程

    • 管理共享变量以及对共享变量操作过程,使得支持并发

    • 线程可以对monitor执行lock和unlock操作进行加锁和释放锁

    • 解决互斥问题的思路:将共享变量及对共享变量的操作统一封装起来

锁优化

  • 同步锁的四种状态:无锁、偏向锁、轻量级锁、重量级锁

  • 偏向锁:一个线程加锁

  • 轻量级锁:两个线程交替自旋

  • 同步锁锁定资源是对象

volatile

可见性:对变量的修改对所有线程可见

  • 可见性

    • volatile在写操作的时候,JVM会发一条lock前缀的指令,将这个缓存的变量会写到系统主存中;其他的使用的时候会从主存读取最新的数据。所以可见

内存屏障(Memory Barrier)是CPU的一种指令,用于控制特定条件下的重排序和内存可见性问题。

Java编译器会根据内存屏障的规则禁止重排序

  • 为了保障volatile变量的可见性和禁止指令重排序,java在字节码中插入内存屏障实现

    • 内存屏障解决指令重排

有序性:禁止指令重排,遵循happens-before原则

双重检验锁必须加volatile,因为内存屏障

  • 双重校验锁实现一个单例

  • 否则会出现空指针

存在问题

  • 不满足原子性

    • 解决方法

      • 使用syn

      • 使用可重入锁

      • 使用原子类

与syn的区别

  • volatile不需要加锁,不会阻塞线程

  • volatile是一种简单的同步机制

JUC

锁的分类

CAS:一条CPU的原子指令,可以保证共享变量修改的原子性

  • 使用unsafe类实现,其中都是native方法

  • 根据内存偏移量找到待更新的原值的准确内存地址,使用compareAndSwaplant将待更新的值和预期值进行比较

  • CAS缺陷

    • 循环时间太长

    • 只能保证一个共享变量原子操作

    • ABA问题

      • 解决方案:AtomicStampedReference(加个时间作为版本号)

问题:Thread 的join方法

与syn的比较

  • syn在以下情况下释放锁

    • 线程执行完释放

    • 线程执行时发生异常,JVM会自动释放

    • 锁方法执行了wait方法,进行释放锁

  • syn的问题

    • 无法控制阻塞时长——>JUC trylock()解决

    • 阻塞不可中断——>lockInterruptibly解决

    • syn不支持读写锁分离——>JUC的ReentrantReadWriteLock锁

AQS

  • 先进先出队列+CAS+volatile

    • 维护一个volatile的int类型的state变量,state=1是获取到锁;state的值变化是由CAS完成的
  • 实现

    • CAS操作提供原子性避免锁

    • volatile确保修改的可见性和内存操作的有序性

ThreadLocal

解决并发问题,在线程中传递数据

通过为每一个线程创建一份共享变量的副本保证各个线程之间的变量访问和修改互不影响

内存泄漏问题

  • 为什么

    • key ThreadLocal的引用缘

      • 栈上的ThreadLocal引用

      • ThreadLocalMap中的key对他的引用

    • value

      • 引用只有一条,从Thread过来的引用
    • 出现的问题

      • ThreadLocal栈上的引用不见了,但是Threadlocal对象因为还有一个引用,索引无法回收

        • 解决方法:ThreadLocal的key改成弱引用
      • Thread对象一直被使用,无法释放

        • 解决方法:对于value,Thread一直没有释放,只有在一个ThreadLocal用完的时候,手动调用一下remove方法
  • 解决

Java中几种集合的排序方式

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 java
字数统计 | 阅读时长
  • 包括实现Comparable接口、借助Comparator比较器进行排序,以及通过Stream API进行排序。同时还解释了Comparable和Comparator的区别、compareTo和equals的使用场景差异,以及Set集合的排序问题。
  1. 实现Comparable接口:
    • Java中的类可以通过实现Comparable接口来具备排序能力。
    • 实现Comparable接口的类需要重写compareTo方法,该方法定义了对象的排序规则。
    • 例如,学生类(Student)可以实现Comparable接口,并按照姓名和年龄进行排序。
  2. 借助Comparator比较器进行排序:
    • 当类本身没有实现Comparable接口,或者需要不同的排序规则时,可以使用Comparator接口。
    • Comparator是一个函数式接口,可以独立于原类之外定义排序逻辑。
    • 例如,可以使用Comparator对学生对象按照姓名和年龄进行排序。
  3. 通过Stream API进行排序:
    • Java 8引入了Stream API,可以方便地对集合进行排序操作。
    • Stream API的sorted方法可以接受一个Comparator作为参数来进行排序。
    • 例如,使用Stream对学生列表进行排序,可以简化排序的代码。
  4. Comparable与Comparator的区别:
    • Comparable用于使类本身具备排序能力,通过实现compareTo方法实现。
    • Comparator是一个独立的比较器,可以为不具备排序能力的类提供排序逻辑,或者提供不同的排序规则。
  5. compareTo与equals的使用场景:
    • compareTo主要用于排序和数值比较,如BigDecimal的比较。
    • equals主要用于判断两个对象在业务语义上是否相同,如String的比较通常使用equals来判断字面意义是否相同。
  6. Set集合的排序问题:
    • Set集合本身是无序的,即元素的插入顺序不保证。
    • 但是,SortedSet接口可以保证元素的排序,通过要求元素实现Comparable接口来实现。
    • LinkedHashSet类通过双向链表记录插入顺序,实现了插入有序的Set。

死锁

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

死锁的定义

死锁是指两个或两个以上的进程(或线程)在执行过程中,因竞争资源或彼此通信而造成的一种阻塞现象。当这些进程都在等待对方释放资源时,就会形成一个无法打破的僵局,若无外力作用,它们都将无法继续执行。此时,系统处于死锁状态。

产生死锁的四个必要条件

  1. 互斥条件:一个资源每次只能被一个进程使用。
  2. 占有且等待:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不可抢占:进程已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

如何解决死锁

解决死锁的方法主要分为预防死锁、避免死锁、检测死锁和解除死锁四种。

  1. 预防死锁:

    • 破坏互斥条件:允许资源被多个进程同时访问(但某些资源可能无法这样做)。
    • 破坏占有且等待条件:要求进程一次性申请所有所需资源,或者允许进程在持有资源的同时申请其他资源(但可能降低系统效率)。
    • 破坏不可抢占条件:允许进程被抢占已分配的资源(但可能导致数据不一致等问题)。
    • 破坏循环等待条件:对资源编号,要求进程按编号顺序申请资源(但可能增加资源管理的复杂性)。
  2. 避免死锁:

    • 使用银行家算法等算法来动态地检查资源分配的安全性,确保系统不会进入不安全状态。
    • 在资源分配过程中,采用资源预分配策略或资源按需分配策略,并监控系统的资源使用情况。
  3. 检测死锁:

    • 定期检查系统是否存在死锁现象,如使用资源分配图等方法。
    • 一旦发现死锁,立即采取措施进行解除。
  4. 解除死锁:

    • 终止一个或多个进程,以打破循环等待条件。
    • 回滚到安全状态,重新分配资源。
    • 在数据库系统中,可以采用自动回滚事务、重启事务等方法来解除死锁。

数据库死锁的发生与解决

在数据库中,死锁通常发生在多个事务并发执行时。当事务A持有资源A的锁并尝试获取资源B的锁时,而事务B持有资源B的锁并尝试获取资源A的锁时,就会发生死锁。

解决数据库死锁的方法包括:

  • 避免并发修改:尽量减少多个事务对同一资源的并发访问。
  • 保证操作顺序:确保多个事务按照相同的顺序访问资源。
  • 使用锁超时机制:设置锁的超时时间,当事务持有锁超过一定时间时自动释放锁。
  • 使用乐观锁或悲观锁等锁策略来管理资源访问。

综上所述,死锁是并发系统中常见的问题之一。通过理解死锁的产生条件、掌握解决死锁的方法以及合理设计资源访问策略,可以有效地预防和解决死锁问题。

线程

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

线程状态的详细解释

  1. 初始(NEW)

    线程对象被创建,但尚未调用start()方法。此时线程还未开始执行,只是作为一个对象存在于内存中。

  2. 运行(RUNNABLE)

    • 就绪(READY): 线程对象创建后,通过调用start()方法启动。此时线程进入就绪状态,等待操作系统的调度,以获取CPU时间片。
    • 运行中(RUNNING): 当就绪状态的线程获得CPU时间片时,开始执行程序代码,进入运行状态。

    在Java中,由于就绪和运行状态的切换非常频繁,且难以准确区分,因此将两者统称为“运行(RUNNABLE)”状态。

  3. 阻塞(BLOCKED)

    线程尝试获取某个对象的锁(如通过synchronized关键字),但锁已被其他线程持有。此时线程进入阻塞状态,直到锁被释放并成功获取。

  4. 等待(WAITING)

    线程通过调用Object类的wait()方法或其他等待方法(如Condition的await()方法)进入等待状态。此时线程需要等待其他线程的通知(通过notify()或notifyAll()方法)或中断来唤醒。

  5. 超时等待(TIMED_WAITING)

    线程通过调用带有超时参数的等待方法(如Thread.sleep(long millis)、Object.wait(long timeout)等)进入超时等待状态。此时线程在指定的时间内等待,如果超时时间到达或收到其他线程的通知,则线程会被唤醒。

  6. 终止(TERMINATED)

    线程执行完毕或由于异常等原因终止执行,进入终止状态。此时线程不再占用系统资源。

状态流转的细化

  • 从初始(NEW)到运行(RUNNABLE): 调用start()方法。
  • 从运行(RUNNABLE)到阻塞(BLOCKED): 尝试获取锁失败。
  • 从阻塞(BLOCKED)到运行(RUNNABLE): 成功获取锁。
  • 从运行(RUNNABLE)到等待(WAITING): 调用wait()等方法。
  • 从等待(WAITING)到运行(RUNNABLE): 收到其他线程的通知或中断。
  • 从运行(RUNNABLE)到超时等待(TIMED_WAITING): 调用带有超时参数的等待方法。
  • 从超时等待(TIMED_WAITING)到运行(RUNNABLE): 超时时间到达或收到其他线程的通知。
  • 从运行(RUNNABLE)到终止(TERMINATED): 线程执行完毕或异常终止。

关于RUNNING状态的缺失

如你所述,由于CPU时间片的分配和线程状态的频繁切换,很难准确区分线程是处于就绪状态还是运行状态。因此,Java将两者统称为“运行(RUNNABLE)”状态,以表示线程当前是可执行的,只要获得CPU时间片就能立即执行。

总结

了解线程的状态及其流转对于编写高效、可靠的并发程序至关重要。通过合理管理线程状态,可以确保程序的正确性和性能。

线程池

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

线程池的解释与实现原理

一、线程池的概念

线程池是池化技术的一种典型实现,所谓池化技术就是提前保存大量的资源,以备不时之需。在机器资源有限的情况下,使用池化技术可以大大提高资源的利用率,提升性能等。线程池,即提前创建好一批线程,并保存在线程池中。当有任务需要执行时,从线程池中选一个线程来执行任务。这样可以避免频繁地创建和销毁线程,从而提高系统的效率和响应速度。

二、线程池的实现

  1. Java中的线程池

    Java中的线程池通过实现ExecutorService接口来提供线程池的功能。Executors类提供了几种创建线程池的方法,如newFixedThreadPool(int Threads)创建固定数目线程的线程池,newCachedThreadPool()创建一个可缓存的线程池,newSingleThreadExecutor()创建一个单线程化的Executor,以及newScheduledThreadPool(int corePoolSize)创建一个支持定时及周期性的任务执行的线程池。

  2. 线程池的主要参数

    • corePoolSize:核心线程数量,可以类比为正式员工数量,常驻线程数量。
    • maximumPoolSize:最大的线程数量,公司最多雇佣员工数量,包括常驻和临时线程数量。
    • workQueue:多余任务等待队列,当任务数量超过当前线程处理能力时,任务会放入此队列等待执行。
    • keepAliveTime:非核心线程空闲时间,即外包人员等待任务的时间,如果超过这个时间还没有任务执行,则会被销毁。
    • threadFactory:创建线程的工厂,可以在这里统一设置创建的线程的属性。
    • handler:线程池拒绝策略,当任务数量超过线程池的处理能力(包括核心线程、最大线程和任务队列)时,会执行此策略,默认是抛出异常。
  3. 线程池的工作原理

    线程池的工作流程大致如下:

    • 当有任务提交到线程池时,首先判断当前线程数量是否小于核心线程数量。如果是,则创建新的线程来执行任务。
    • 如果当前线程数量已经达到核心线程数量,但任务队列未满,则将任务放入任务队列等待执行。
    • 如果任务队列已满,但当前线程数量小于最大线程数量,则创建新的非核心线程来执行任务。
    • 如果当前线程数量已经达到最大线程数量,且任务队列已满,则根据拒绝策略处理新提交的任务。
  4. 线程池的execute方法

    execute方法是线程池的核心方法,用于向线程池中添加一个任务。该方法的实现逻辑相对复杂,但大致可以分为以下几个步骤:

    • 首先判断线程池的状态和当前线程数量,以及任务队列的状态。
    • 如果满足条件,则尝试创建新的线程来执行任务。
    • 如果不满足条件,则根据拒绝策略处理新提交的任务。
  5. 添加工作线程

    添加工作线程的过程是通过addWorker方法实现的。该方法首先判断线程池的状态和当前线程数量,然后尝试创建新的线程。创建线程的过程可能会受到多种因素的影响,如核心线程数量、最大线程数量、任务队列状态等。

三、总结

线程池是一种高效的并发处理机制,通过提前创建并保存一批线程,当有任务需要执行时,从线程池中选取一个线程来执行任务。这样可以避免频繁地创建和销毁线程,从而提高系统的效率和响应速度。Java中的线程池通过实现ExecutorService接口来提供线程池的功能,并提供了多种创建线程池的方法和参数配置选项。了解线程池的工作原理和实现机制对于编写高效、可扩展的并发程序具有重要意义。

java中的集合

发表于 2024-10-10 | 更新于 2025-09-14 | 分类于 java
字数统计 | 阅读时长

Java中的集合类有哪些?如何分类的?

Java的集合框架中主要包含以下几种数据结构:

  1. List(列表):有序集合,可以包含重复元素。常见的实现类有ArrayList(基于动态数组实现)和LinkedList(基于链表实现)。
  2. Set(集合):无序集合,不包含重复元素。常见的实现类有HashSet(基于哈希表实现)和TreeSet(基于红黑树实现)。
  3. Queue(队列):一种先进先出(FIFO)的数据结构。常见的实现类有LinkedList(也可以作为队列使用)、ArrayDeque(双端队列)和PriorityQueue(优先队列)。
  4. Stack(栈):一种后进先出(LIFO)的数据结构。在Java中,Stack类继承自Vector类,但现在推荐使用Deque接口的实现类(如ArrayDeque)来代替Stack。
  5. Map(映射):存储键值对(K-V对)的数据结构。常见的实现类有HashMap(基于哈希表实现)和TreeMap(基于红黑树实现)。

从继承关系上讲,List、Set和Queue都是Collection接口的子接口,而Collection接口又继承了Iterable接口。这意味着这些集合都是可以遍历的。

从功能上讲:

  • List代表一个有序容器,元素可以重复。
  • Set是无序的(除了TreeSet,它是有序的),并且元素不可重复。
  • Map存储键值对,通过键来访问值。

从实现上讲:

  • List可以通过链表(如LinkedList)或数组(如ArrayList)实现。
  • Queue可以有不同的实现,如优先队列(PriorityQueue)和双端队列(ArrayDeque)。
  • Map的实现包括普通的HashMap和可以排序的TreeMap。

知识扩展:

Collection和Collections有什么区别?

  1. Collection是一个集合接口,提供了对集合对象进行基本操作的通用接口方法。它是List、Set等的父接口。
  2. Collections是一个包装类,包含各种有关集合操作的静态多态方法。它不能实例化,就像一个工具类,服务于Java的Collection框架。

Java中的Collection如何遍历迭代?

  1. 传统的for循环遍历,基于计数器。
  2. 迭代器遍历,使用Iterator接口。
  3. foreach循环遍历,内部也是采用了Iterator的方式实现。
  4. 迭代器遍历,使用Enumeration接口,这是Iterator的“古老版本”。
  5. Stream API,JDK 1.8中新增,使用一种类似用SQL语句从数据库查询数据的直观方式来提供一种对Java集合运算和表达的高阶抽象。

Iterable和Iterator如何使用?

  • Iterator接口代表迭代的方式,包含next和hasNext方法。
  • Iterable接口代表的是是否可以迭代,如果可以迭代,会返回Iterator接口,即返回迭代方式。

为什么不把Iterable和Iterator合成一个使用?

  1. Iterable和Iterator并不是同时出现的,Iterator先于Iterable出现,目的是为了代替Enumeration。
  2. 将“是否可以迭代”和“迭代方式”抽出来,更符合单一职责原则,使得迭代方式可以被多个可迭代的集合复用,更符合面向对象的特点。

Autowired和@Resource的区别?

发表于 2024-09-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

@Autowired和@Resource的区别

口语化答案

好的,面试官,Autowired 和 Resource 都是依赖注入注解。一个是 spring 框架带的,一个是 javaee 框架带的。Autowired 主要是类型注入,Resource 是按照名称注入,名称找不到的话,会按照类型进行注入。Autowired 当存在多个的时候,可以配合Qualifier 来进行使用。一般在实际工作中比较常用 Resource。以上。

题目解析

常考题,面试官喜欢问,其实两者比较好区分,记住一个是类型,一个是名称即可。然后就是提供方的不同。

面试得分点

类型注入,名称注入,指定名称。

题目详细答案

@Autowired和@Resource都是用于依赖注入的注解。

@Autowired

来源:Spring 框架。

注入方式:默认按类型注入。

用法:可以用于字段、构造器、Setter 方法或其他任意方法。

可选性:可以与@Qualifier一起使用,以指定具体的 Bean。

处理机制:Spring 的AutowiredAnnotationBeanPostProcessor处理@Autowired注解。

@Resource

来源:由 Java EE 提供。

注入方式:默认按名称注入,如果按名称找不到,则按类型注入。

用法:可以用于字段或 Setter 方法。

属性:可以指定name和type属性。

处理机制:Spring 的CommonAnnotationBeanPostProcessor处理@Resource注解。

详细比较

  1. 注入方式:

@Autowired:默认按类型注入。如果需要按名称注入,可以结合@Qualifier注解使用。

@Resource:默认按名称注入。如果名称匹配失败,则按类型注入。

  1. 属性:

@Autowired:没有name和type属性,但可以使用@Qualifier指定名称。

@Resource:有name和type属性,可以明确指定要注入的 Bean 名称或类型。

  1. 兼容性:

@Autowired:是 Spring 框架特有的注解。

@Resource:是 Java 标准注解,兼容性更广,适用于任何支持 JSR-250 的容器。

  1. 使用场景:

@Autowired:在 Spring 应用中更为常见,尤其是在需要按类型注入的场景中。

@Resource:在需要兼容标准 Java EE 规范的应用中更为常见,或者在需要明确指定 Bean 名称时使用。

Transactional底层实现?

发表于 2024-09-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

@Transactional底层实现?

  1. 解析**@Transactional**注解:
    • Spring在启动时会扫描所有带有@Transactional注解的类和方法,并解析注解中的属性,生成TransactionAttribute对象。
  2. 创建代理对象:
    • Spring使用AOP创建代理对象,代理对象会拦截对目标方法的调用。
  3. 事务拦截器拦截方法调用:
    • 当代理对象的方法被调用时,TransactionInterceptor会拦截该调用。
  4. 获取事务属性:
    • TransactionInterceptor从TransactionAttributeSource获取当前方法的事务属性。
  5. 事务管理器处理事务:
    • 根据事务属性,TransactionInterceptor会通过TransactionManager开启一个新事务或加入一个现有事务。
  6. 执行目标方法:
    • 事务开始后,TransactionInterceptor会调用目标方法。
  7. 提交或回滚事务:
    • 如果目标方法执行成功,TransactionInterceptor会通过TransactionManager提交事务。
    • 如果目标方法抛出异常,根据事务属性中的回滚规则,TransactionInterceptor会决定是否回滚事务。
  8. 清理事务上下文:
    • 事务提交或回滚后,TransactionSynchronizationManager会清理事务上下文,确保线程的事务状态一致。

相关概念

1. AOP(面向切面编程)

Spring的声明式事务管理主要依靠AOP来实现。AOP允许在方法执行之前和之后添加额外的行为(如事务管理)。

2. 事务管理器(Transaction Manager)

Spring提供了多种事务管理器实现,如:

  • DataSourceTransactionManager:用于JDBC数据源的事务管理。
  • JpaTransactionManager:用于JPA的事务管理。
  • HibernateTransactionManager:用于Hibernate的事务管理。

这些事务管理器负责具体的事务处理逻辑。

3. 事务拦截器(Transaction Interceptor)

TransactionInterceptor是AOP的一个拦截器,用于拦截带有@Transactional注解的方法。它会在方法执行之前和之后执行事务管理逻辑。

4. 事务属性(Transaction Attributes)

@Transactional注解的属性(如传播行为、隔离级别、超时、只读等)会被解析为TransactionAttribute对象。这些属性定义了事务的具体行为。

5. 事务同步管理器(Transaction Synchronization Manager)

TransactionSynchronizationManager用于管理事务的同步状态。它维护了当前线程的事务状态,并负责在事务开始、提交或回滚时调用相应的回调。

6. 事务代理(Transaction Proxy)

Spring使用代理模式来实现声明式事务管理。代理对象会拦截对目标方法的调用,并在调用目标方法之前和之后执行事务管理逻辑。

7. 事务上下文(Transaction Context)

事务上下文包含了当前事务的状态信息,如事务是否已经开始、是否需要回滚等。Spring会在事务开始时创建事务上下文,并在事务结束时清理它。

LinkedList 真的比 ArrayList 添加元素快吗

发表于 2024-09-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

👌LinkedList 真的比 ArrayList 添加元素快吗?

题目详细答案

性能的比较需要根据具体的操作和数据分布来分析。

尾部插入

ArrayList:在尾部插入元素时,当数据量较小时,由于ArrayList需要频繁扩容,可能会稍显慢一些。但当数据量较大时,ArrayList的扩容策略(通常是当前容量的1.5倍)可以一次提供很多空间,减少了扩容的次数,从而在尾部插入效率上可能超过LinkedList。

LinkedList:在数据量较小时,尾部插入数据较快,因为每次添加只需要新建一个节点并调整指针。但当数据量大时,每次add()操作都会新建一个节点,这可能会增加时间消耗。

首部插入

ArrayList:在首部插入元素时,由于需要将原数组所有元素向后移动一个位置(通过System.arraycopy方法),效率相对较低。

LinkedList:在首部插入元素时,只需要调整首尾节点的指针,时间复杂度为O(1),因此效率较高。

中间插入

ArrayList:在中间插入元素时,同样需要将原数组的元素向后移动以腾出位置,时间复杂度为O(n),其中n为数组长度。但插入位置越往后,需要复制后移的数据越少,效率相对会高一些。

LinkedList:在中间插入元素时,需要遍历链表找到插入位置,然后从两端向中间搜索,index越往中间遍历越久,因此效率相对较低。但在数据量较小时,LinkedList的性能可能会超过ArrayList,因为ArrayList在数据量小时需要频繁扩容。

注意事项

内存消耗:LinkedList的每个节点都需要额外的空间来存储指针信息,因此在内存消耗上可能会比ArrayList稍大。

线程安全:ArrayList和LinkedList都不是线程安全的。如果需要在多线程环境下使用,需要考虑额外的同步措施。

综上所述,LinkedList和ArrayList在添加元素时的性能优劣取决于具体的操作和数据分布。在尾部插入大量数据时,ArrayList可能更优;在首部插入数据时,LinkedList更优;而在中间插入数据时,需要根据数据量的大小和插入位置来具体分析。

在 SQL 查询中,WHERE 和 HAVING 都可以用来过滤数据

发表于 2024-09-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

在 SQL 查询中,WHERE 和 HAVING 都可以用来过滤数据,但它们的工作机制不同:

WHERE:在分组(GROUP BY)之前过滤数据,属于预过滤。
HAVING:在分组(GROUP BY)之后过滤数据,属于后过滤。
将适合放在 WHERE 条件的过滤写在 HAVING 中,可能导致大量不必要的数据参与分组运算,从而增加查询的时间和资源消耗。

示例
原始表
假设有一个销售记录表 sales:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE sales (
id INT PRIMARY KEY,
product_id INT,
sale_date DATE,
amount DECIMAL(10, 2)
);
数据样例:

id product_id sale_date amount
1 101 2024-01-01 100.00
2 102 2024-01-02 200.00
3 101 2024-01-03 300.00
4 103 2024-01-04 400.00
5 101 2024-01-05 500.00
查询需求
我们需要统计每个产品的总销售金额大于 500 的记录。

错误示例:使用 HAVING

SELECT product_id, SUM(amount) AS total_amount
FROM sales
GROUP BY product_id
HAVING product_id = 101 AND total_amount > 500;
问题:

HAVING product_id = 101 是一个简单的等值条件,但它放在 HAVING 中,会导致所有数据都参与 GROUP BY 和 SUM 运算。
这增加了计算负担,尤其是当数据量很大时。
优化:使用 WHERE
sql

1
2
3
4
5
SELECT product_id, SUM(amount) AS total_amount
FROM sales
WHERE product_id = 101
GROUP BY product_id
HAVING total_amount > 500;

改进:

在 WHERE 中预先过滤出 product_id = 101 的数据,减少了参与 GROUP BY 和 SUM 运算的数据量。
查询效率显著提升。
性能对比
在数据量较大时,比如有 1,000,000 条记录:

HAVING 方式:所有记录都会参与分组计算,资源消耗高,时间长。
WHERE 方式:只有满足 product_id = 101 的数据参与分组,减少了无用的计算。
优化后,查询时间可能降低 30%-50% 或更多,具体视数据量和索引情况而定。

SQL 查询执行顺序
SQL 的逻辑执行顺序与书写顺序不同,逻辑执行顺序如下:

FROM:从数据源(表、视图等)加载数据。
ON:应用 JOIN 条件(仅适用于多表查询)。
JOIN:将符合 ON 条件的表进行连接。
WHERE:过滤不符合条件的行(行级过滤)。
GROUP BY:将数据分组。
HAVING:对分组后的结果进行过滤(基于聚合条件)。
SELECT:选择所需的列或表达式。
DISTINCT:去重。
ORDER BY:对结果集排序。
LIMIT:返回指定数量的行。

执行顺序分析
优化后的 SQL 调整了条件位置:

WHERE 先执行:WHERE product_id = 101 在 GROUP BY 之前过滤掉了不符合条件的行,减少了参与分组的记录数量。
减少分组和聚合运算的负担:GROUP BY 和 SUM 只需处理过滤后的数据。
HAVING 用于聚合结果过滤:HAVING total_amount > 500 只对已经分组后的结果应用。
效果:大幅减少了分组和聚合运算的记录数,提升了性能。

区别:WHERE:在分组之前过滤数据,作用于行。
HAVING:在分组之后过滤数据,作用于聚合结果。

mysql索引失效的场景

  • 建立联合索引,id-name-create_time-account_date(入账时间)
    建立了id-name索引,但是查询条件中有create_time 没有使用索引下推,需要建立一个id-name-create_time索引,会走索引下推;
  • 其中order by 两个时间的排序方式不一致。索引失效

解决hash碰撞的方法

发表于 2024-09-16 | 更新于 2025-09-14 | 分类于 面试
字数统计 | 阅读时长

解决hash碰撞的方法?

题目详细答案

链地址法(Chaining)

链地址法是最常见的解决哈希碰撞的方法之一。在这种方法中,每个桶(bucket)包含一个链表(或树结构,Java 8 及以上版本)。当发生哈希碰撞时,新的键值对被添加到相应桶的链表中。

优点:

简单易实现。

动态调整链表长度,不需要提前知道元素数量。

缺点:

当链表长度增加时,查找效率下降。

需要额外的存储空间来存储指针。

1
2
3
4
5
6
7
8
9
10
class HashMapNode<K, V> {
K key;
V value;
HashMapNode<K, V> next;

HashMapNode(K key, V value) {
this.key = key;
this.value = value;
}
}

开放地址法(Open Addressing)

开放地址法不使用链表,而是在哈希表本身寻找空闲位置来存储碰撞的元素。常见的开放地址法有以下几种:

线性探测(Linear Probing)

当发生哈希碰撞时,线性探测法在哈希表中向后依次查找下一个空闲位置。

优点:实现简单,不需要额外的存储空间。

缺点:当哈希表接近满时,查找效率急剧下降(称为“主群集”问题)。

1
2
3
4
5
int hash = key.hashCode() % table.length;
while (table[hash] != null) {
hash = (hash + 1) % table.length;
}
table[hash] = new Entry(key, value);

二次探测(Quadratic Probing)

二次探测法在发生哈希碰撞时,按照平方序列查找空闲位置(如 1, 4, 9, 16, …)。

优点:减少主群集问题。

缺点:实现较复杂,可能会导致二次群集问题。

1
2
3
4
5
6
7
int hash = key.hashCode() % table.length;
int i = 1;
while (table[hash] != null) {
hash = (hash + i * i) % table.length;
i++;
}
table[hash] = new Entry(key, value);

双重散列(Double Hashing)

双重散列法使用两个不同的哈希函数。当第一个哈希函数发生碰撞时,使用第二个哈希函数计算新的索引。

优点:减少群集问题。较好的查找性能。

缺点:实现复杂。需要设计两个有效的哈希函数。

1
2
3
4
5
6
int hash1 = key.hashCode() % table.length;
int hash2 = 1 + (key.hashCode() % (table.length - 1));
while (table[hash1] != null) {
hash1 = (hash1 + hash2) % table.length;
}
table[hash1] = new Entry(key, value);

再哈希法(Rehashing)

再哈希法在发生碰撞时,使用不同的哈希函数重新计算哈希值,直到找到空闲位置。

优点:减少群集问题。

缺点:实现复杂。需要设计多个有效的哈希函数。

分离链接法

在 Java 8 及以上版本中,当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树,以提高查找效率。

优点:在高冲突情况下性能较好,动态调整链表和树的长度。

缺点:实现复杂,需要额外的存储空间。

其他方法

Cuckoo Hashing:使用两个哈希表和两个哈希函数,如果插入时发生冲突,将原来的元素“踢出”并重新插入到另一个哈希表中。

Hopscotch Hashing:类似于线性探测,但在插入时会调整元素的位置,使得查找路径更短。

链地址法是最常见的解决哈希碰撞的方法,适用于大多数情况。开放地址法在空间利用率上有优势,但在高负载情况下性能可能下降。再哈希法和其他高级方法适用于特定的高性能需求场景。

synchronized

发表于 2024-09-10 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

synchronized

1. synchronized 的使用方法

  • 同步方法:在方法声明时加上 synchronized 关键字,这样当某个线程调用这个方法时,会先获取到该方法的锁(通常是该方法所属对象的锁),其他线程必须等待锁被释放后才能调用这个方法。
  • 同步代码块:使用 synchronized(对象) 来定义一个同步代码块,这里的对象就是锁对象。当线程进入这个代码块时,会先尝试获取这个对象的锁,获取到锁后才能执行代码块中的代码。

2. synchronized 的实现机制

  • 方法级同步:对于同步方法,JVM 在方法的常量池中添加一个 ACC_SYNCHRONIZED 标志。当线程调用这个方法时,会检查这个标志,如果设置了该标志,则需要先获取到方法的锁(通常是该方法所属对象的监视器锁),然后开始执行方法,方法执行完毕后再释放锁。
  • 代码块级同步:对于同步代码块,JVM 使用 monitorenter 和 monitorexit 两条字节码指令来实现同步。monitorenter 指令用于获取锁,monitorexit 指令用于释放锁。每个对象都有一个监视器锁(monitor),当线程执行到 monitorenter 指令时,会尝试获取对象的监视器锁,如果获取成功,则计数器加一;当线程执行到 monitorexit 指令时,计数器减一。当计数器为 0 时,表示锁已经被释放,其他线程可以获取锁。

3. Monitor(监视器)

  • Monitor 是 Java 中用于实现同步的一种机制,它可以看作是一个特殊的对象,这个对象包含了一个特殊的房间(Entry Set)和一个等待房间(Wait Set)。
  • 当线程尝试获取对象的锁时,它会在 Entry Set 中等待,直到锁被释放。
  • 如果线程在持有锁的过程中因为某些原因被挂起(比如调用了 wait() 方法),那么它会被移到 Wait Set 中,等待其他线程唤醒它(比如调用 notify() 或 notifyAll() 方法)。
  • Monitor 保证了同一时间只有一个线程可以访问被保护的数据和代码。

4. synchronized 的特性

  • 互斥性:同一时间点,只有一个线程可以获得锁,获得锁的线程才能处理被 synchronized 修饰的代码片段。
  • 阻塞性:只有获得锁的线程才能执行被 synchronized 修饰的代码片段,未获得锁的线程只能阻塞,等待锁释放。
  • 可重入性:如果一个线程已经获得锁,在锁未释放之前,再次请求锁的时候,是必然可以获得锁的。这是因为 JVM 会维护一个锁计数器,当同一个线程多次获取锁时,计数器会递增;当释放锁时,计数器会递减,直到计数器为 0 时,锁才会被真正释放。

综上所述,synchronized 通过在方法或代码块级别添加同步机制,利用对象的监视器锁来保证线程安全。它是 Java 中实现线程同步的一种简单而有效的手段。

synchronized 是 Java 中用于保证线程安全的关键字,它通过一系列的机制来保证原子性、可见性和有序性。下面是对这三个方面的详细解释:

synchronized特性

1. 原子性

原子性是指一个操作是不可中断的,即该操作要么全部执行,要么全部不执行。在并发编程中,原子性用于保证某个操作在执行过程中不会被其他线程打断。

在 Java 中,synchronized 通过 monitorenter 和 monitorexit 这两个字节码指令来保证原子性。当一个线程进入 synchronized 修饰的方法或代码块时,它会先尝试获取锁(通过 monitorenter 指令)。如果获取成功,则继续执行后续的代码;如果获取失败(因为锁已被其他线程持有),则该线程会被阻塞,直到锁被释放(通过 monitorexit 指令)为止。

由于 synchronized 保证了同一时间只有一个线程能够持有锁并执行相应的代码,因此它也就保证了这段代码在执行过程中的原子性。即使由于时间片耗尽或其他原因导致线程被中断,只要锁还没有被释放,该线程在下一次获得时间片时仍然会继续执行剩余的代码,直到完成。

2. 可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。

Java 内存模型(JMM)规定了所有的变量都存储在主内存中,而每个线程都有自己的工作内存(也称为线程本地存储)。线程对变量的所有操作都必须在自己的工作内存中进行,而不能直接读写主内存。这可能导致线程1修改了某个变量的值,但线程2由于还没有从主内存中刷新该变量的副本,因此看不到修改后的值。

synchronized 关键字通过确保在进入同步块或同步方法时获取锁,并在退出时释放锁,来实现对变量的可见性保证。具体来说,当一个线程持有锁并执行同步代码块时,它会将自己工作内存中的变量副本更新到主内存中(在写操作时)。当其他线程尝试进入该同步代码块时,它们会先获取锁,并在获取锁后从主内存中读取最新的变量值到自己的工作内存中(在读操作时)。这样,就保证了线程之间对共享变量的可见性。

3. 有序性

有序性是指程序执行的顺序按照代码的先后顺序执行。然而,由于硬件和编译器的优化,指令可能会被重排序以提高性能。这种重排序在单线程环境下通常不会改变程序的执行结果,但在多线程环境下可能会导致问题。

Java 提供了 as-if-serial 语义来确保单线程程序的有序性。该语义要求编译器和处理器在优化时不能改变单线程程序的执行结果。然而,在多线程环境下,as-if-serial 语义并不能完全保证有序性。

为了解决这个问题,synchronized 关键字通过确保同一时间只有一个线程能够执行同步代码块来提供有序性保证。由于同步代码块在同一时间只能被一个线程执行,因此可以认为该代码块内的指令是按照它们在代码中出现的顺序执行的。这避免了由于指令重排序而导致的多线程问题。

总结来说,synchronized 通过确保同一时间只有一个线程能够执行同步代码块来提供原子性、可见性和有序性保证。这些特性使得 synchronized 成为 Java 中实现线程安全的一种重要手段。

锁升级

这篇文章的核心内容是介绍了Java中synchronized关键字的锁升级过程,主要包括无锁、偏向锁、轻量级锁和重量级锁四种状态。以下是对这些核心内容的简要概述:

1.无锁状态:

  • 当一个线程第一次访问一个对象的同步块时,JVM会在对象头中设置该线程的Thread ID,并将对象头的状态位设置为“偏向锁”。

2.偏向锁:

  • 当一个synchronized块被线程首次进入时,锁对象会进入偏向模式。
  • 偏向锁模式下,锁会偏向于第一个获取它的线程,JVM会在对象头中记录该线程的ID作为偏向锁的持有者。
  • 如果其他线程访问该对象,会先检查该对象的偏向锁标识,如果和自己的线程ID相同,则直接获取锁。如果不同,则该对象的锁状态就会升级到轻量级锁状态。
  • 触发条件:首次进入synchronized块时自动开启,假设JVM启动参数没有禁用偏向锁。
  • 注意:在JDK 15中,偏向锁已被废除。

3.轻量级锁:

  • 当有另一个线程尝试获取已被偏向的锁时,偏向锁会被撤销,锁会升级为轻量级锁。
  • 在轻量级锁状态中,JVM为对象头中的Mark Word预留了一部分空间,用于存储指向线程栈中锁记录的指针。
  • 当一个线程尝试获取轻量级锁时,JVM会:
    1. 将对象头中的Mark Word复制到线程栈中的锁记录(Lock Record)。
    2. 尝试通过CAS操作更新对象头的Mark Word。
  • 如果替换成功,则该线程获取锁成功;如果失败,则表示已经有其他线程获取了锁,则该锁状态就会升级到重量级锁状态。
  • 触发条件:当有另一个线程尝试获取已被偏向的锁时,偏向锁会升级为轻量级锁。

4.重量级锁:

  • 当轻量级锁的CAS操作失败,即出现了实际的竞争,锁会进一步升级为重量级锁。
  • 当锁状态升级到重量级锁状态时,JVM会将该对象的锁变成一个重量级锁,并在对象头中记录指向等待队列的指针。
  • 如果一个线程想要获取该对象的锁(当前对象已被其他线程锁定时),则需要先进入等待队列,等待该锁被释放。当锁被释放时,JVM会从等待队列中选择一个线程唤醒,并将该线程的状态设置为“就绪”状态,然后等待该线程重新获取该对象的锁。
  • 触发条件:当轻量级锁的CAS操作失败,轻量级锁升级为重量级锁。
<i class="fa fa-angle-left"></i>1…101112<i class="fa fa-angle-right"></i>

239 日志
22 分类
30 标签
GitHub
© 2025 javayun
由 Hexo 强力驱动
主题 - NexT.Gemini