- 1. Java 集合
- 1.1 Java 中常见的集合有哪些?
- 1.2 线程安全的集合有哪些?
- 1.3 Collections 和 Collection 的区别是什么?
- 1.4 集合遍历的方法有哪些?
- 1.5 ArrayList 和 LinkedList 的异同点是什么?
- 1.6 ArrayList 的扩容机制是什么?
- 1.7 为什么 ArrayList 不是线程安全的,具体来说是哪里不安全?
- 1.8 CopyOnWriteArrayList 是如何实现线程安全的?
- 1.9 HashMap 的底层数据结构是什么?
- 1.10 哈希冲突的解决方法有哪些?
- 1.11 HashMap 的 Put 和 Get 过程介绍一下?
- 1.12 HashMap 调用 Get 方法一定安全吗?
- 1.13 为什么 String 适合做 HashMap 的 Key?
- 1.14 为什么 HashMap 要用红黑树而不是平衡二叉树?
- 1.15 重写 HashMap 的 equals() 和 hashCode() 方法需要注意什么?
- 1.16 为什么在解决 Hash 冲突的时候,不直接用红黑树,而是先用链表再转红黑树?
- 1.17 什么是负载因子?为什么 HashMap 的默认负载因子为 0.75?
- 1.18 ConcurrentHashMap 是怎么实现的?
- 2. 多线程
Java 进阶面试题总结,涉及集合、并发、JVM 等内容,文章将不断更新。
1. Java 集合
1.1 Java 中常见的集合有哪些?
Java 中的集合主要可以分为四个部分:List
、Set
、Map
和工具类(如 Iterator
迭代器、Enumeration
枚举类、Arrays
和 Collections
)。这些集合类主要由两个接口派生而来,即 Collection
(包含 List
、Set
、Queue
)和 Map
,它们是集合框架的根接口。
List
:一种有序列表的集合,例如,按索引排列的元素的List
。常见的实现类有ArrayList
、LinkedList
和Vector
。ArrayList
是容量可变的非线程安全列表,其底层使用数组实现。当几何扩容时,会创建更大的数组,并把原数组的内容复制到新数组中。ArrayList
支持对元素的快速随机访问,但插入与删除速度很慢。LinkedList
本质是一个双向链表,与ArrayList
相比,其插入和删除速度更快,但随机访问速度更慢。
Set
:一种保证没有重复元素的集合。常见的实现类有HashSet
、LinkedHashSet
和TreeSet
。HashSet
通过HashMap
实现,HashMap
的 Key 即为HashSet
存储的元素,所有 Key 都是用相同的 Value,一个名为PRESENT
的Object
类型常量。使用 Key 保证元素唯一性,但不保证有序性。由于HashSet
是HashMap
实现的,因此线程不安全。LinkedHashSet
继承自HashSet
,通过LinkedHashMap
实现,使用双向链表维护元素插入顺序。TreeSet
通过TreeMap
实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。
Map
:一种通过键值(Key-Value)查找的映射表集合,Key 无序且唯一;Value 不要求有序,允许重复,Map
没有继承于Collection
接口。常见的实现类有HashMap
、TreeMap
、Hashtable
、LinkedHashMap
和ConcurrentHashMap
。HashMap
:JDK1.8 之前HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(拉链法解决冲突),JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。LinkedHashMap
:LinkedHashMap
继承自HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序,同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。Hashtable
:数组+链表组成的,数组是Hashtable
的主体,链表则是主要为了解决哈希冲突而存在的,Hashtable
是线程安全的。TreeMap
:红黑树(自平衡的排序二叉树)。ConcurrentHashMap
:Node
数组+链表+红黑树实现,是线程安全的(JDK1.8 以前 Segment 锁,1.8 以后 Volatile + CAS 或者 Synchronized)
1.2 线程安全的集合有哪些?
Java 中的线程安全集合主要包括以下几种:
java.util
包中的线程安全的类主要有 3 个,其他都是非线程安全的:Vector
:这是一个线程安全的动态数组,其内部方法基本都经过synchronized
关键字修饰,它提供了与ArrayList
类似的功能,但每个方法都是同步的,这意味着在多线程环境下,它的性能会比ArrayList
差,毕竟同步是有额外开销的。。Stack
:这是一个线程安全的栈实现,它继承自Vector
。Hashtable
:这是一个线程安全的哈希表实现,类似于HashMap
,但是Hashtable
的方法都是同步的,HashTable
的加锁方法是给每个方法加上synchronized
关键字,这样锁住的是整个 Table 对象,不支持null
键和值,由于同步导致的性能开销,所以已经很少被推荐使用,如果要使用线程安全的哈希表,可以用ConcurrentHashMap
。
java.util.concurrent
包提供的都是线程安全的集合:ConcurrentLinkedQueue
:这是一个线程安全的队列实现,适用于高并发场景,使用了非阻塞算法,它通过无锁的方式(CAS),实现了高并发状态下的高性能,性能要好于BlockingQueue
。BlockingQueue
接口的实现类,如ArrayBlockingQueue
,LinkedBlockingQueue
等。BlockingQueue
的主要功能并不是在于提升高并发时的队列性能,而在于简化多线程间的数据共享。CopyOnWriteArrayList
和CopyOnWriteArraySet
:这是两个线程安全的集合,它们会在修改操作时复制一份数据,避免了修改时的并发问题。其中所有写操作(add
、set
等)都通过对底层数组进行全新复制来实现,允许存储null
元素,当对象进行写操作时,使用了 Lock 锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换掉旧数组;若进行读操作,则直接返回结果,操作过程中不需要进行同步。CopyOnWriteArraySet
和HashSet
虽然都继承于共同的父类AbstractSet
,但是HashSet
是通过散列表实现的,而CopyOnWriteArraySet
则是通过动态数组CopyOnWriteArrayList
实现的。ConcurrentHashMap
:这是一个线程安全的HashMap
,它通过将数据分段,从而达到并发控制,性能要优于Hashtable
,它与HashTable
的主要区别是二者加锁粒度的不同。ConcurrentSkipListMap
:线程安全且排序的哈希表,实现了一个基于 SkipList(跳表)算法的可排序的并发集合,SkipList 是一种可以在对数预期时间内完成搜索、插入、删除等操作的数据结构,通过维护多个指向其他元素的跳跃链接来实现高效查找。
值得注意的是,为了保证集合是线程安全的,相应的效率也比较低;线程不安全的集合效率相对会高一些。如果你的代码只在一个线程中运行,或者多个线程只是读取集合而不修改集合,那么你可以选择线程不安全的集合,因为它们的性能通常会更好。
1.3 Collections 和 Collection 的区别是什么?
Collection
是 Java 集合框架中的一个接口,它是所有集合类的基础接口。它定义了一组通用的操作和方法,如添加、删除、遍历等,用于操作和管理一组对象。Collection
接口有许多实现类,如 List
、Set
和 Queue
等。
Collections
是 Java 提供的一个工具类,位于 java.util
包中。它提供了一系列静态方法,用于对集合进行操作和算法。Collections
类中的方法包括排序、查找、替换、反转、随机化等等,这些方法可以对实现了 Collection
接口的集合进行操作,如 List
和 Set
。
1.4 集合遍历的方法有哪些?
(1)普通 for
循环:可以使用带有索引的普通 for
循环来遍历 List
:
1 | List<Integer> list = new ArrayList<>(List.of(1, 2, 3)); |
(2)增强 for
循环(for-each
循环):用于循环访问数组或集合中的元素:
1 | List<Integer> list = new ArrayList<>(List.of(1, 2, 3)); |
(3)Iterator
迭代器:可以使用迭代器来遍历集合,特别适用于需要删除元素的情况:
1 | List<Integer> list = new ArrayList<>(List.of(1, 2, 3)); |
(4)ListIterator
列表迭代器:ListIterator
是迭代器的子类,可以双向访问列表并在迭代过程中修改元素:
1 | List<Integer> list = new ArrayList<>(List.of(1, 2, 3)); |
(5)使用 forEach
方法:Java 8 引入了 forEach
方法,可以对集合进行快速遍历:
1 | List<Integer> list = new ArrayList<>(List.of(1, 2, 3)); |
(6)Stream API:Java 8 的 Stream API 提供了丰富的功能,可以对集合进行函数式操作,如过滤、映射等:
1 | List<Integer> list = new ArrayList<>(List.of(1, 2, 3)); |
1.5 ArrayList 和 LinkedList 的异同点是什么?
ArrayList
和 LinkedList
都是 Java 中常用的 List
实现类,它们有一些共同点,也有一些不同点。
- 共同点:
ArrayList
和LinkedList
都是单列集合中List
接口的实现类,它们都是存取允许重复,且有序的元素。 - 不同点:
- 底层数据结构:
ArrayList
是基于动态数组实现的,底层使用数组来存储元素。而LinkedList
是基于链表实现的,底层使用双向链表来存储元素。 - 随机访问:对于随机访问
get
和set
方法,ArrayList
的速度通常优于LinkedList
,因为ArrayList
可以根据下标以O(1)
的时间复杂度对元素进行随机访问,而LinkedList
的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的时间复杂度是O(n)
。 - 插入和删除操作:对于插入和删除操作,
LinkedList
的速度通常优于ArrayList
,因为当元素被添加到LinkedList
任意位置的时候,不需要像ArrayList
那样重新计算大小或者是更新索引。ArrayList
在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。 - 内存占用:
LinkedList
比ArrayList
更占内存,因为LinkedList
的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。而ArrayList
使用数组来存储元素,因此插入和删除元素时需要移动其他元素占用内存,所以在频繁进行插入和删除操作时,ArrayList
的性能会比较低,且可能会造成内存浪费。
- 底层数据结构:
1.6 ArrayList 的扩容机制是什么?
ArrayList
的扩容机制是其核心特性之一。在 ArrayList
中添加元素时,如果当前的数组已经满了,那么 ArrayList
会创建一个新的、更大的数组,并将原有数组的元素复制到新的数组中,这个过程就叫做扩容。
具体来说,ArrayList
的扩容机制如下:
- 当向
ArrayList
中添加元素时,首先会检查ArrayList
的当前大小(也就是它内部的数组大小)是否能够容纳新的元素。如果可以,那么新元素就直接被添加到ArrayList
中。 - 如果
ArrayList
的当前大小不足以容纳新的元素,那么ArrayList
就需要进行扩容操作。在扩容操作中,ArrayList
会创建一个新的数组,新数组的大小是原数组大小的 1.5 倍,这个值是在 JDK 的源码中定义的,之所以扩容是 1.5 倍,是因为 1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数:newCapacity = oldCapacity + (oldCapacity >> 1)
。 - 接着,
ArrayList
会使用System.arraycopy
方法,将原有数组中的所有元素复制到新的数组中。 - 最后,新的数组会替代原有的数组,成为
ArrayList
的内部数组。
值得注意的是,ArrayList
的扩容操作需要重新分配内存空间,并将原来的元素复制到新的数组中,这可能会导致性能问题。因此,在实例化 ArrayList
时设置足够的初始容量,并且尽可能减少数组扩容的次数,可以帮助提高性能:
1 | List<Integer> list = new ArrayList<>(100); |
1.7 为什么 ArrayList 不是线程安全的,具体来说是哪里不安全?
首先 ArrayList
的 add
添加元素方法的代码如下:
1 | public boolean add(E e) { |
在高并发添加数据下,ArrayList
会暴露以下三个问题:
(1)部分值为 null
(我们并没有 add(null)
进去)
当线程 1 走到了扩容那里发现当前 size
是 9,而数组容量是 10,所以不用扩容,这时候 CPU 让出执行权,线程 2 也进来了,发现 size
是 9,而数组容量是 10,所以不用扩容,这时候线程 1 继续执行,将数组下标索引为 9 的位置 set
值了,还没有来得及执行 size = s + 1
,这时候线程 2 也来执行了,又把数组下标索引为 9 的位置 set
了一遍,这时候两个线程先后执行 size = s + 1
,导致下标索引 10 的地方就为 null
了。
(2)索引越界异常
线程 1 走到扩容那里发现当前 size
是 9,数组容量是 10 不用扩容,CPU 让出执行权,线程 2 也发现不用扩容,这时候数组的容量就是 10,而线程 1 set
完之后执行 size = s + 1
,这时候线程 2 再进来 size
就是 10,数组的大小只有 10,而你要设置下标索引为 10 的位置就会越界。
(3)size
与我们 add
的数量不符
这个基本上每次都会发生,这个理解起来也很简单,因为 size = s + 1
本身就不是原子操作,可以分为三步:获取 s
的值,将 s
的值加 1,将新的 s
值覆盖掉原来的 size
,线程 1 和线程 2 拿到一样的 s
值加完了同时覆盖,就会导致有一次没有加上。
1.8 CopyOnWriteArrayList 是如何实现线程安全的?
CopyOnWriteArrayList
底层也是通过一个数组保存数据,使用 volatile
关键字修饰数组,保证当前线程对数组对象重新赋值后,其他线程可以及时感知到:
1 | private transient volatile Object[] array; |
接下来看一下 CopyOnWriteArrayList
添加元素的源码:
1 | public boolean add(E e) { |
写入新元素时,首先会先将原来的数组拷贝一份并且让原来数组的长度加一后就得到了一个新数组,新数组里的元素和旧数组的元素一样并且长度比旧数组多一个长度,然后将新加入的元素放置在新数组最后一个位置,最后用新数组的地址替换掉老数组的地址就能得到最新的数据了。
在我们执行替换地址操作之前,读取的是老数组的数据,数据是有效数据;执行替换地址操作之后,读取的是新数组的数据,同样也是有效数据,而且使用该方式能比读写都加锁要更加的效率。
现在我们来看读操作,读是没有加锁的,所以读是一直都能读:
1 | public E get(int index) { |
1.9 HashMap 的底层数据结构是什么?
HashMap
的底层数据结构主要包括哈希表(数组)、链表和红黑树。
- 哈希表(数组):
HashMap
主要依赖于哈希表来存储数据。哈希表中的每个元素被称为槽位(Bucket)。数组的每个位置都可以存放一个元素(键值对),数组的索引是通过键的哈希码经过哈希函数计算得来的。这样我们就可以通过键快速定位到数组的某个位置,取出相应的值,这就是HashMap
快速获取数据的原理。 - 链表:在理想的情况下,哈希函数将每个键均匀地散列到哈希表的各个位置。但在实际中,我们可能会遇到两个不同的键计算出相同的哈希值,这就是所谓的哈希冲突。
HashMap
通过使用链表来解决这个问题。当哈希冲突发生时,HashMap
会在冲突的 Bucket 增加一个链表,新的元素会被添加到链表的末尾。每个链表中的元素都包含了相同哈希值的键值对。 - 红黑树:从 Java 8 开始,如果链表的长度超过一定的阈值(默认为 8),那么链表会被转换为红黑树。红黑树是一种自平衡的二叉查找树,通过保持树的平衡,可以提高查找效率。
1.10 哈希冲突的解决方法有哪些?
- 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
- 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
- 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
- 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。
1.11 HashMap 的 Put 和 Get 过程介绍一下?
存储对象时,我们将 <Key, Value>
传给 put(key, val)
方法时,它调用 hashCode()
方法计算哈希值从而得到 Bucket 位置,检查该位置是否为空,如果为空则直接在该位置创建一个新的 Entry
对象来存储键值对,否则检查该位置的第一个键值对的哈希码和键是否与要添加的键值对相同,如果相同则表示找到了相同的键,直接将新的值替换旧的值,完成更新操作,否则需要遍历链表或红黑树来查找是否有相同的键。完成插入后 HashMap
会根据当前 Bucket 的占用情况自动调整容量(Load Factor 超过阈值则扩容为原来的 2 倍)。
获取对象时,我们将 Key 传给 get(key)
方法,它调用 hashCode()
计算哈希值从而得到 Bucket 位置,并进一步调用 equals()
方法确定键值对。
1.12 HashMap 调用 Get 方法一定安全吗?
不一定,调用 get()
方法有几点需要注意的地方:
- 空指针异常(NullPointerException):如果你尝试用
null
作为键调用get()
方法,而HashMap
没有被初始化(即为null
),那么会抛出空指针异常。不过,如果HashMap
已经初始化,使用null
作为键是允许的,因为HashMap
支持null
键(当 Key 为空时,直接令 Key 的哈希值为 0)。 - 线程安全:
HashMap
本身不是线程安全的。如果在多线程环境中,没有适当的同步措施,同时对HashMap
进行读写操作可能会导致不可预测的行为。例如,在一个线程中调用get()
方法读取数据,而另一个线程同时修改了结构(如增加或删除元素),可能会导致读取操作得到错误的结果或抛出ConcurrentModificationException
。如果需要在多线程环境中使用类似HashMap
的数据结构,可以考虑使用ConcurrentHashMap
。
1.13 为什么 String 适合做 HashMap 的 Key?
String
对象是不可变的,一旦创建就不能被修改,这确保了 Key 的稳定性。如果 Key 是可变的,可能会导致 hashCode()
和 equals()
方法的不一致,进而影响 HashMap
的正确性。
1.14 为什么 HashMap 要用红黑树而不是平衡二叉树?
平衡二叉树追求的是完全平衡的状态,即任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。
红黑树不追求这种完全平衡状态,而是追求一种弱平衡状态,即整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡二叉树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁做调整,这也是我们为什么大多数情况下使用红黑树的原因。
1.15 重写 HashMap 的 equals() 和 hashCode() 方法需要注意什么?
HashMap
在比较元素时,会先通过 hashCode()
进行比较,相同的情况下再通过 equals()
进行比较。所以 equals()
相等的两个对象,hashCode()
一定相等,而 hashCode()
相等的两个对象,equals()
不一定相等(如散列冲突的情况)。
所有不允许存储重复数据的集合类都使用 hashCode()
和 equals()
去查找重复,所以正确实现它们非常重要。equals()
和 hashCode()
的实现应该遵循以下规则:
- 如果
o1.equals(o2)
,那么o1.hashCode() == o2.hashCode()
总是为true
的。 - 如果
o1.hashCode() == o2.hashCode()
,并不意味着o1.equals(o2)
一定为true
。
1.16 为什么在解决 Hash 冲突的时候,不直接用红黑树,而是先用链表再转红黑树?
在解决 Hash 冲突的时候,HashMap
在链表长度大于 8 的时候才会将链表转换为红黑树,而不是直接使用红黑树,这主要有以下几个原因:
- 查询效率:红黑树的平均查找长度是
log(n)
,当链表长度为 8 时,查找长度为log(8) = 3
,而链表的平均查找长度为n / 2
,当长度为 8 时,平均查找长度为8 / 2 = 4
。因此,当链表长度小于等于 8 时,使用链表的查询效率其实并不比红黑树差。 - 插入效率和空间效率:链表的插入操作比红黑树快,且链表的空间占用也比红黑树小。因此,在元素数量较少时,使用链表比红黑树更高效。
- 防止频繁转换:如果链表长度在 8 左右徘徊,且频繁地进行插入和删除操作,可能会导致链表和红黑树之间频繁地转换,这会降低效率。因此,
HashMap
设计了两个阈值,链表长度超过 8 时转为红黑树,少于 6 时转回链表,这样可以减少转换的频率。
总的来说,这种设计是为了在保证查询效率的同时,尽可能地提高插入效率和空间效率,以及减少因频繁转换而带来的开销。
1.17 什么是负载因子?为什么 HashMap 的默认负载因子为 0.75?
负载因子是用于表示哈希表中元素填满的程度的一个参数。在哈希表(如 Java 的 HashMap
)中,负载因子是和扩容机制有关的,当哈希表中的元素个数超过了容量乘以负载因子时,就会进行扩容。例如,如果初始容量是 16,负载因子是 0.75,16 * 0.75 = 12
,也就是说,当容量达到了 12 的时候就会进行扩容操作。
负载因子的大小对哈希表的性能有重要影响。如果负载因子过大,那么哈希表中的冲突会更频繁,导致查找效率降低。反之,如果负载因子过小,那么哈希表的空间利用率就会降低,导致内存浪费。因此,选择一个合适的负载因子,可以在时间效率和空间效率之间达到一个平衡。在 Java 的 HashMap
中,负载因子的默认值是 0.75,这是一个在时间和空间效率之间的折衷选择。
1.18 ConcurrentHashMap 是怎么实现的?
在 JDK 1.7 中它使用的是分段数组+链表的形式实现的,而数组又分为:大数组 Segment
和小数组 HashEntry
。Segment
是一种可重入锁(ReentrantLock),在 ConcurrentHashMap
里扮演锁的角色,HashEntry
则用于存储键值对数据。一个 ConcurrentHashMap
里包含一个 Segment
数组,一个 Segment
里包含一个 HashEntry
数组,每个 HashEntry
是一个链表结构的元素。
在 JDK 1.7 中,ConcurrentHashMap
虽然是线程安全的,但因为它的底层实现是数组+链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表。JDK 1.8 则使用了数组+链表/红黑树的方式优化了 ConcurrentHashMap
的实现。
JDK 1.8 ConcurrentHashMap
主要通过 volatile
加 CAS(乐观锁)或者 synchronized
(悲观锁)来实现的线程安全的。添加元素时首先会判断容器是否为空:
- 如果为空则使用
volatile
加 CAS 来初始化。 - 如果容器不为空,则根据存储的元素计算该位置是否为空。
- 如果为空,则利用 CAS 设置该节点;
- 如果不为空,则使用
synchronized
,然后遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
相当于是 ConcurrentHashMap
通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment
来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。
通过上面的介绍可以知道 ConcurrentHashMap
悲观锁和乐观锁都有用到。
2. 多线程
2.1 Java 里面的线程和操作系统的线程一样吗?
Java 底层会调用 pthread_create
来创建线程,所以本质上 Java 程序创建的线程,就是和操作系统线程是一样的,是一对一的线程模型。
2.2 使用多线程要注意哪些问题?
要保证多线程的程序安全,不要出现数据竞争造成的数据混乱的问题。Java 的线程安全在三个方面体现:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在 Java 中使用了
atomic
包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized
关键字来确保原子性。 - 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在 Java 中使用了
synchronized
和volatile
这两个关键字确保可见性。 - 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在 Java 中使用了
happens-before
原则来确保有序性。
2.3 保证数据的一致性有哪些方案呢?
- 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过 ACID 属性,数据库事务可以保证数据的一致性。
- 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用
synchronized
关键字、ReentrantLock
或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。 - 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。
2.4 线程的创建方式有哪些?
(1)继承 Thread
类
这是最直接的一种方式,用户自定义类继承 java.lang.Thread
类,重写其 run()
方法,run()
方法中定义线程执行的具体任务。创建该类的实例后,通过调用 start()
方法启动线程。
1 | class MyThread extends Thread { |
这种创建方式的优缺点如下:
- 优点:编写简单,如果需要访问当前线程,无需使用
Thread.currentThread()
方法,直接使用this
即可获得当前线程。 - 缺点:因为线程类已经继承了
Thread
类,所以不能再继承其他的父类。
(2)实现 Runnable
接口
实现 Runnable
接口需要重写 run()
方法,然后将此 Runnable
对象作为参数传递给 Thread
类的构造器,创建 Thread
对象后调用其 start()
方法启动线程。
1 | class MyRunnable implements Runnable { |
这种创建方式的优缺点如下:
- 优点:线程类只是实现了
Runable
接口,还可以继承其他的类,在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU 代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。 - 缺点:编程有点复杂,如果需要访问当前线程,必须使用
Thread.currentThread()
方法。
(3)实现 Callable
接口与 FutureTask
java.util.concurrent.Callable
接口类似于 Runnable
,但 Callable
的 call()
方法可以有返回值并且可以抛出异常。要执行 Callable
任务,需将它包装进一个 FutureTask
,因为 Thread
类的构造器只接受 Runnable
参数,而 FutureTask
实现了 Runnable
接口。这种创建方式的优缺点与 Runnable
一样。
1 | class MyCallable implements Callable<Integer> { |
(4)使用线程池(Executor 框架)
Java 5 开始引入的 java.util.concurrent.ExecutorService
和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过 Executors
类的静态方法创建不同类型的线程池。
1 | class Task implements Runnable { |
这种创建方式的优缺点如下:
- 优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化 CPU 利用率和系统吞吐量。
- 缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。
2.5 如何停止一个线程的运行?
启动线程是通过 Thread
类的 start()
,停止线程则主要有以下这些方法:
- 异常法停止:线程调用
interrupt()
方法后,在线程的run()
方法中判断当前对象的interrupted()
状态,如果是中断状态则抛出异常,达到中断线程的效果。 - 在沉睡中停止:先将线程 Sleep,然后调用
interrupt()
标记中断状态,interrupt()
会将阻塞状态的线程中断,会抛出中断异常,达到停止线程的效果。 - 暴力停止:线程调用
stop()
方法会被暴力停止,方法已弃用,强制让线程停止有可能使一些请理性的工作得不到完成。 - 使用
return
停止线程:调用interrupt()
标记为中断状态后,在run()
方法中判断当前线程状态,如果为中断状态则return
,能达到停止线程的效果。
2.6 Java 线程的状态有哪些?
java.lang.Thread.State
枚举类中定义了六种线程的状态,可以调用 Thread
中的 getState()
方法获取当前线程的状态。
NEW
:尚未启动的线程状态,即线程创建,还未调用start()
方法。RUNNABLE
:就绪状态(调用start()
,等待调度),此时线程正在运行。BLOCKED
:等待监视器锁时,陷入阻塞状态。WAITING
:等待状态的线程正在等待另一线程执行特定的操作(如notify
)TIMED_WAITING
:具有指定等待时间的等待状态。TERMINATED
:线程完成执行,进入终止状态。
2.7 sleep() 和 wait() 有什么区别?
- 所属分类的不同:
sleep()
是Thread
类的静态方法,可以在任何地方直接通过Thread.sleep()
调用,无需依赖对象实例。wait()
是Object
类的实例方法,这意味着必须通过对象实例来调用。 - 锁释放的情况:
sleep()
在调用时,线程会暂停执行指定的时间,线程会释放 CPU,主动让出 CPU 时间片,进入TIMED_WAITING
状态,但不会释放持有的对象锁。也就是说,在 Sleep 期间,其他线程无法获得该线程持有的锁。调用wait()
方法时,线程会释放持有的对象锁,进入等待状态,直到其他线程调用相同对象的notify()
或notifyAll()
方法唤醒它。 - 使用条件:
sleep()
可在任意位置调用,无需事先获取锁。wait()
必须在同步块或同步方法内调用(即线程需持有该对象的锁),否则抛出IllegalMonitorStateException
。 - 唤醒机制:
sleep()
休眠时间结束后,线程自动恢复到就绪状态,等待 CPU 调度。wait()
需要其他线程调用相同对象的notify()
或notifyAll()
方法才能被唤醒。notify()
会随机唤醒一个在该对象上等待的线程,而notifyAll()
会唤醒所有在该对象上等待的线程。
2.8 BLOCKED 和 WAITING 有什么区别?
BLOCKED
和 WAITING
两个状态最大的区别有两个:
BLOCKED
是锁竞争失败后被动触发的状态,WAITING
是人为主动触发的状态。BLOCKED
的唤醒是自动触发的,而WAITING
状态是必须要通过特定的方法来主动唤醒。
具体展开解释如下:
- 触发条件:线程进入
BLOCKED
状态通常是因为试图获取一个对象的锁,但该锁已经被另一个线程持有。这通常发生在尝试进入synchronized
块或方法时,如果锁已被占用,则线程将被阻塞直到锁可用。线程进入WAITING
状态是因为它正在等待另一个线程执行某些操作,例如调用Object.wait()
方法、Thread.join()
方法或LockSupport.park()
方法。在这种状态下,线程将不会消耗 CPU 资源,并且不会参与锁的竞争。 - 唤醒机制:当一个线程等待锁被阻塞时,一旦锁被释放,线程将有机会重新尝试获取锁。如果锁此时未被其他线程获取,那么线程可以从
BLOCKED
状态变为RUNNABLE
状态。线程在WAITING
状态中则需要被显式唤醒。例如,如果线程调用了wait()
,那么它必须等待另一个线程调用同一对象上的notify()
或notifyAll()
方法才能被唤醒。
2.9 不同的线程之间如何通信?
共享变量是最基本的线程间通信方式,多个线程可以访问和修改同一个共享变量,从而实现信息的传递。为了保证线程安全,通常需要使用 synchronized
关键字或 volatile
关键字。
1 | public class SharedVariableExample { |
上面这段代码中生产者线程每隔 2 秒将 flag
设置为 true
,消费者线程每隔 0.5 秒判断一次 flag
是否为 true
,如果为 true
则将其改为 false
。volatile
关键字确保了 flag
变量在多个线程之间的可见性,即一个线程修改了 flag
的值,其他线程能立即看到。
Object
类中的 wait()
、notify()
和 notifyAll()
方法可以用于线程间的协作。wait()
方法使当前线程进入等待状态,notify()
方法唤醒在此对象监视器上等待的单个线程,notifyAll()
方法唤醒在此对象监视器上等待的所有线程。
1 | public class WaitNotifyExample { |
上面的代码中 lock
是一个用于同步的对象,生产者和消费者线程都需要获取该对象的锁才能执行相应的操作。注意我们先启动的是消费者线程,消费者获取对象锁后生产者还处于阻塞状态,消费者线程此时判断 flag
为 false
,于是调用 lock.wait()
方法进入等待状态,并释放锁,生产者线程执行完生产任务后调用 lock.notify()
方法唤醒等待的消费者线程。