经过一个多月的准备和面试,给大家汇报一下最新的面试结果。在这一个多月的时间中在boss上面投递了六七十份简历,通知面试的有十家左右,最终收到offer的有三家公司。进过考虑选择了一个家年薪30w的公司,这个水平在杭州以及工作5年大数据的来说,不算太好,但是也达到的自己的心里预期,也不枉费我这一个多月的面试准备。最后也祝愿每一位面试人都能得到自己想要的结果。
好了,接着和大家分享Java相关的面试题,本篇文章主要包含Java的集合、异常、并发、IO以及虚拟机相关的面试,总共71题,3万字左右。以问答的形式呈现,这样也能更好的模拟面试的环节。本文的答案部分有图有代码,这样解释会更加详细和易懂。
1:常见的集合容器
集合容器分为两类,一类是Collection,一类是Map
2:Collection和Collections有什么区别
-
Collection 是JDK中集合层次结构中的最根本的接口。定义了集合类的基本方法
-
Collections 是一个包装类。它包含有各种有关集合操作的静态多态方法,不能实例化,Collection 集合框架的工具类
3:HashMap和Hashtable 有什么区别
-
线程安全性:Hashtable是线程安全的,它的方法都是同步的,可以在多线程环境下使用,但性能相对较低。而HashMap是非线程安全的,它的方法不是同步的,适合在单线程环境下使用。如果在多线程环境下使用HashMap,可以使用ConcurrentHashMap来替代,它提供了线程安全的操作并且具有更好的性能
-
null值:HashMap允许键和值为null且key有且仅有一个null值,而Hashtable不允许键和值为null。当需要存储null值时,应选择HashMap
-
hash的计算方式不同。HashMap 计算了 hash值;Hashtable 使用了 key 的 hashCode方法
-
默认初始大小和扩容方式不同。HashMap 默认初始大小 16,容量必须是 2 的整数次幂,扩容时将容量变为原来的2倍;Hashtable 默认初始大小 11,扩容时将容量变为原来的 2 倍加 1
-
是否有 contains 方法。HashMap 没有 contains 方法;Hashtable 包含 contains 方法,类似于 containsValue
-
父类不同。HashMap 继承自 AbstractMap;Hashtable 继承自 Dictionary
4:ArrayList和LinkedList有什么区别
-
内部实现:ArrayList基于数组实现,使用动态数组来存储元素(Arrays.copyOf()方法实现动态数组),而LinkedList则是基于链表实现,使用双向链表来存储元素
-
插入和删除操作:ArrayList对于随机访问和修改元素效率较高,但在插入和删除操作时,需要移动其他元素,效率较低。LinkedList在插入和删除操作时,只需要修改相邻节点的指针,效率较高
-
访问和搜索操作:ArrayList通过索引可以快速访问元素,时间复杂度为O(1),而LinkedList需要遍历链表来访问指定位置的元素,时间复杂度为O(n)。对于搜索操作,ArrayList也较快,因为可以利用索引进行快速定位,而LinkedList需要遍历整个链表,时间复杂度为O(n)
-
内存占用:ArrayList在内存中需要连续的存储空间,而LinkedList不需要连续的存储空间,每个节点都可以分散存储,因此在内存占用方面,LinkedList可能会占用更多的空间
-
迭代器支持:ArrayList提供了快速而稳定的迭代器支持,可以在迭代过程中进行元素的添加和删除操作。LinkedList的迭代器支持相对较慢,因为每次迭代都需要从头或尾部开始遍历链表
5:Array和ArrayList有什么区别
-
大小固定 vs 大小可变:Array的大小在创建时就被确定,无法改变;而ArrayList的大小是可变的,可以根据需要动态增加或减少元素
-
原始类型 vs 对象类型:Array可以存储原始类型(如int、char、boolean等)和对象类型(如String、自定义类等),而ArrayList只能存储对象类型,不能存储原始类型,因为ArrayList内部使用的是Object数组
-
自动装箱和拆箱:Array在存储原始类型时不会进行自动装箱和拆箱操作,而ArrayList在存储原始类型时会自动进行装箱和拆箱操作
-
内存分配:Array在创建时需要分配连续的内存空间,大小固定;而ArrayList在创建时会分配一个初始容量的数组,当元素数量超过当前容量时,会自动进行扩容操作
-
功能和灵活性:ArrayList提供了一系列方便的方法来添加、删除、搜索和遍历元素,以及动态调整容量等操作,提供了更多的功能和灵活性。而Array的功能相对较少,需要手动编写代码来实现类似的操作
6:Queue的add()和offer()方法有什么区别
两者的返回值不同,add()方法在添加元素时,如果成功则返回true,如果队列已满则抛出异常;而offer()方法在添加元素时,如果成功则返回true,如果队列已满则返回false。
7:Queue的remove()和poll()方法有什么区别
两者的返回值不同:remove()方法在移除元素时,如果队列非空则返回被移除的元素,如果队列为空则抛出异常;而poll()方法在移除元素时,如果队列非空则返回被移除的元素,如果队列为空则返回null。
8:Queue的element()和peek()方法有什么区别
两者的返回返回值不同:element()方法返回队列头部的元素,如果队列为空则抛出异常;而peek()方法返回队列头部的元素,如果队列为空则返回null。
9:Iterator怎么使用,有什么特点
在集合容器中Collection集成了Iterable接口,所以所有Collection的子类都可以使用迭代器进行元素的遍历。下面是常用的一些方法:
next() 方法获得集合中的下一个元素
hasNext() 检查集合中是否还有元素
remove() 方法将迭代器新返回的元素删除
forEachRemaining(Consumer<? super E> action) 方法,遍历所有元素
10:怎么确保一个集合不能被修改
有以下三种方式可以将可变集合转变为不可变集合(不可变集合的好处就是线程安全)
-
使用Collections类的unmodifiable方法:通过调用Collections.unmodifiableCollection(collection)方法可以将现有的集合包装成不可变集合,返回的对象是一个只读的包装器。
-
使用Guava库:Guava提供了丰富的不可变集合类,例如ImmutableList、ImmutableSet和ImmutableMap等,可以通过这些类直接创建不可变集合。
-
使用Java 9+的新特性:Java 9引入了一些新的工厂方法,例如List.of、Set.of和Map.of等,可以方便地创建不可变集合。
11:为什么基本类型不能做为HashMap的键值
-
基本类型(如int、char、boolean等)不能直接作为HashMap的键值,是因为HashMap的键值对是基于对象的,而基本类型并不是对象。
-
HashMap是基于哈希表实现的,它使用键值对的形式存储数据。在HashMap中,键(Key)用于索引数据,值(Value)则是与键关联的数据。HashMap使用键的哈希值来确定存储位置,通过哈希值可以快速定位和访问对应的值。
-
由于基本类型不是对象,它们没有对应的哈希值生成算法和哈希值比较方法。而在HashMap中,要进行键的哈希值的计算和比较,需要依赖键对象的hashCode()方法和equals()方法。因此,基本类型无法直接作为HashMap的键,需要将基本类型转换为对应的包装类对象(如Integer、Character、Boolean等)才能作为键使用
12:TreeSet的原理是什么
TreeSet是Java中的一种有序集合实现,它基于红黑树(Red-Black Tree)数据结构来存储和管理元素。红黑树是一种自平衡的二叉搜索树,通过保持树的平衡性,可以在对数时(O(logN))内进行插入、删除和查找操作。
在使用TreeSet时需要注意以下几点:
-
元素的可比较性:TreeSet要求元素是可比较的,要么实现Comparable接口并定义自然顺序,要么通过构造TreeSet时传入比较器来指定元素的比较方式。
-
不允许为null:TreeSet不允许存储null元素,如果插入null元素会抛出NullPointerException。
-
性能开销:由于红黑树的维护和平衡操作,TreeSet的插入、删除和查找操作在最坏情况下的时间复杂度为O(logN),比较其他集合实现的性能开销稍高。
-
线程不安全:TreeSet不是线程安全的,如果在多线程环境下使用,需要采取适当的同步措施来保证线程安全性
13:HashSet实现原理是什么
HashSet是Java中的一种集合实现,它基于哈希表(Hash Table)数据结构来存储和管理元素,在内部存储数据使用的是HashMap,其中key存储具体的数据,而value为object对象。由于不同元素可能具有相同的哈希码,可能会发生哈希冲突。当发生哈希冲突时,HashSet会使用链表或红黑树来解决冲突。链表通过将冲突的元素串联在一起,红黑树则通过在桶中存储一个平衡的二叉搜索树来提高查找效率。
HashSet的实现具有以下特点:
-
元素唯一性:HashSet不允许存储重复的元素,通过哈希码和equals()方法来判断元素是否重复。
-
无序性:HashSet不保证元素的顺序,元素在哈希表中的位置与它们的插入顺序无关。
-
快速存取:由于使用哈希表作为底层数据结构,HashSet提供快速的插入、删除和查找操作,平均时间复杂度为O(1)。
14:Map的遍历方式有哪些
-
使用entrySet()方法遍历:通过Map的entrySet()方法获取键值对的Set视图,然后使用迭代器或增强型for循环遍历键值对,再通过Entry对象的getKey()和getValue()方法获取键和值。Map<String, Integer> map = new HashMap<>(); // 添加键值对到map... // 使用entrySet()方法遍历Map for (Map.Entry<String, Integer> entry : map.entrySet()) { String key = entry.getKey(); Integer value = entry.getValue(); // 处理键值对 }
-
使用keySet()方法遍历:通过Map的keySet()方法获取键的Set视图,然后使用迭代器或增强型for循环遍历键,再通过键获取对应的值
-
使用values()方法遍历:通过Map的values()方法获取值的Collection视图,然后使用迭代器或增强型for循环遍历值
-
使用Java 8引入的Stream API来遍历Map,使用Stream的forEach()方法对键值对进行处理
15:说一下HashMap的实现原理
HashMap基于Hash 算法实现,通过 put(key,value) 存储,get(key) 来获取 value。
在get(key)的时候会通过hash(key)计算出数组的下标索引,通过下标找到数组中对应的节点,然后再遍历节点(链表或者树)找到与key相等的元素value。
get()方法
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 检查表是否为空或长度为0,获取第一个节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果第一个节点与给定的键匹配,则直接返回第一个节点
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果第一个节点是树节点,则调用树节点的查找方法
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 遍历链表以查找匹配的键
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 如果没有找到匹配的键,则返回null
return null;
}
在put(key,value),通过hash(key)计算出数组的下标索引,判断该下标下的数组值是否为空,如果为空直接创建一个结点,如果不为空说明存在节点,判断节点是否为数组,如果是节点为数组则在数据添加元素,如果结点是链表则判断添加元素后链表的长度是否超多8,如果超过则将链表转化为树。最后判断是否需要扩容,如果需要扩容则调用resize()方法进行扩容。
put()方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 检查表是否为空或者长度为0,如果是,则进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算桶的索引并获取桶中的第一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 检查第一个节点是否与给定的键匹配
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果第一个节点是树节点,则插入到树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表以找到匹配的键
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 在链表末尾插入新节点
p.next = newNode(hash, key, value, null);
// 如果链表长度超过阈值,则将链表转换为树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1是为了第一个节点
treeifyBin(tab, hash);
break;
}
// 检查链表中的键是否匹配
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // 存在相同键的映射
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 在访问节点后执行必要的操作
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
// 在插入节点后执行必要的操作
afterNodeInsertion(evict);
return null;
}
resize()方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; // 获取旧的哈希表数组引用
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 获取旧的容量
int oldThr = threshold; // 获取旧的阈值
int newCap, newThr = 0; // 新的容量和阈值
// 如果旧容量大于0,则进行扩容操作
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 双倍的阈值
}
// 如果旧容量为0,则使用阈值作为初始容量
else if (oldThr > 0)
newCap = oldThr;
else { // 零初始阈值表示使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; // 更新阈值
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 创建新的哈希表数组
table = newTab; // 将哈希表的引用指向新的哈希表数组
// 将旧表中的元素重新散列到新表中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 清空旧表中的索引位置
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e; // 直接放入新表中,不需要再进行链表操作
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 将链表转化为红黑树
else { // 保持顺序
Node<K,V> loHead = null, loTail = null; // 低位链表的头节点和尾节点
Node<K,V> hiHead = null, hiTail = null; // 高位链表的头节点和尾节点
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 低位链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 高位链表
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead; // 将低位链表放入新表中的对应位置
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; // 将高位链表放入新表中的对应位置
}
}
}
}
}
return newTab; // 返回新的哈希表数组
}
16:说一下ConcurrentHashMap的实现原理
ConcurrentHashMap基于Hash算法实现,在节点Node中使用volatile定义节点内容和下个节点索引。通过 put(key,value) 存储,get(key) 来获取 value。
在get(key)的时候会通过spread(key)计算出数组的下标索引,使用Unsafe.getObjectVolatile()方法获取数组下标的节点数据,然后再遍历节点(链表或者树)找到与key相等的元素value。
get()方法
public V get(Object key) {
Node<K,V>[] tab; // 哈希表数组
Node<K,V> e, p; // 当前节点、首节点
int n, eh; // 数组长度、首节点哈希
K ek; // 首节点键
int h = spread(key.hashCode()); // 计算键的哈希值并进行扰动
// 判断哈希表数组不为空且长度大于0,并获取哈希表中与哈希值对应的首节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 如果首节点的哈希值与目标哈希值相等
if ((eh = e.hash) == h) {
// 如果首节点的键与目标键相等,则返回首节点的值
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果首节点的哈希值小于0,表示首节点为树节点,进行树节点的查找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 遍历链表或树,查找键对应的节点并返回其值
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
// 未找到对应的键,返回null
return null;
}
在put(key,value)首先通过spread(key)计算出数组的下标索引,然后判断数组是否为空如果为空则初始化数组,如果不为空则Unsafe.getObjectVolatile()获取hash所对应的数组中的节点元素,判断节点元素是否为空,如果为空则通过Unsafe.compareAndSwapObject()直接创建一个结点,如果不为空说明存在节点,然后使用synchronized锁住该节点元素对象,然后添加元素,在添加元素完成后判断添加元素后链表的长度是否超多8,如果超过则将链表转化为树。最后调用addCount()方法为元素个数+1,这个过程中会判断是否需要扩容,如果需要扩容则调用transfer()进行扩容。
put()方法
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;
// 如果哈希表为空或长度为0,则进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 如果哈希值对应的位置为空,使用CAS操作将节点添加到该位置
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // 添加到空的槽位时无需加锁,直接跳出循环
}
// 如果首节点的哈希值为MOVED,说明正在进行扩容操作,需要帮助进行迁移
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 对首节点加锁进行操作
synchronized (f) {
// 再次检查首节点是否改变
if (tabAt(tab, i) == f) {
// 如果首节点的哈希值大于等于0,表示链表节点
if (fh >= 0) {
binCount = 1;
// 遍历链表,查找键是否已存在
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果键已存在,则更新值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
// 遍历到链表末尾,将新节点添加到末尾
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
// 如果首节点是树节点
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 在树节点中插入键值对
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 如果节点数量不为0
if (binCount != 0) {
// 如果节点数量达到阈值,则将链表转化为树节点
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 如果已存在相同的键,则返回旧值
if (oldVal != null)
return oldVal;
break;
}
}
}
// 更新计数器,并返回null
addCount(1L, binCount);
return null;
}
transter()方法
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 计算迁移的步长
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // 将范围细分
// 如果下一个表还未初始化,则进行初始化操作
if (nextTab == null) { // 初始化
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // 尝试处理 OOME(内存溢出)
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // 确保在提交 nextTab 前进行扫描
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 判断是否需要继续前进
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 边界检查
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 迁移完成,更新表和大小控制
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 调整大小控制
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 判断是否需要进行下一轮迁移
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // 在提交前重新检查
}
}
else if ((f = tabAt(tab, i)) == null)
// 将当前位置设置为 ForwardingNode,表示迁移正在进行
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // 已处理过
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
// 处理普通链表节点的迁移
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
// 处理红黑树节点的迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) : (hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) : (lc != 0) ? new TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
17:BIO、NIO、AIO 有什么区别
-
BIO:线程发起 IO 请求 ,不管内核是否准备好 IO 操作,从发起请求起,线程一直阻塞,直到操作完成 同步并阻塞 ,服务器实现模式为一个连接一个线程,即客户端有连接请 求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造 成不必要的线程开销, 当然可以通过线程池机制改善
-
NIO:线程发起 IO 请求,立即返回; 内核在做好 IO 操作的准备之后,通过调用注册的回调函数通知线程做 IO 操作, 线程开始阻塞,直到操作完成 同步非阻塞 ,服务器实现模式为一个请求一个线程,即客户端发送的连 接请求都会注册到多路复用器上, 多路复用器轮询到连接有 I/O 请求时才启动 一个线程进行处理
-
AIO:线程发起 IO 请求,立即返回; 内存做好 IO 操作的准备之后 ,接着做IO操 作, 直到操作完成或者失败,通过调用注册的回调函数通知线程做 IO 操作完成或者失败 异步非阻塞 ,服务器实现模式为一个有效请求一个线程,客户端的 IO 请 求都是由 OS 先完成了再通知服务器应用去启动线程进行处理
18:Java 中 IO 流分为几种
-
字节流(Byte Streams):字节流以字节为单位进行输入和输出。它们用于处理二进制数据(例如图像、音频、视频等)或以字节为基础的文本数据。在Java中,字节流主要由InputStream和OutputStream类及其子类组成。常见的字节流类有FileInputStream、FileOutputStream、ByteArrayInputStream、ByteArrayOutputStream等。
-
字符流(Character Streams):字符流以字符为单位进行输入和输出。它们用于处理文本数据,并且可以处理字符集编码的问题。在Java中,字符流主要由Reader和Writer类及其子类组成。常见的字符流类有FileReader、FileWriter、BufferedReader、BufferedWriter等。
19:OSI模型的层级介绍
-
应用层(Application Layer):提供应用程序访问网络的接口,包括协议和服务(如HTTP、FTP、SMTP等)
-
表示层(Presentation Layer):处理数据的表示、加密和解密,确保不同系统之间的数据格式兼容
-
会话层(Session Layer):管理通信会话的建立、维护和结束
-
传输层(Transport Layer):提供端到端的可靠数据传输,通过端口号标识应用程序之间的通信
-
网络层(Network Layer):负责在不同网络之间路由数据,处理分组(Packet)的传输
-
数据链路层(Data Link Layer):在通信实体之间提供可靠的数据传输,通过帧(Frame)将数据分割为更小的单元
-
物理层(Physical Layer):负责传输比特流,处理物理接口和电信号
20:TCP和UDP的区别
TCP:是一种面向连接的、可靠的传输层协议。它提供了在网络中可靠地传输数据的机制,确保数据的完整性、顺序性和可靠性。
1:面向连接:在使用TCP传输数据之前,发送方和接收方需要通过握手过程建立一个连接。连接的建立包括三次握手,即发送方发送连接请求,接收方进行确认,最后发送方再进行确认。连接建立后,双方可以进行数据传输。
2:可靠性保证:TCP使用确认机制和重传机制来确保数据的可靠传输。接收方会对接收到的数据包进行确认,发送方会根据接收方的确认情况进行重传,确保数据的正确性。如果数据丢失或损坏,TCP会重新发送丢失或损坏的数据,直到接收方正确接收。
3:拥塞控制:TCP具有拥塞控制机制,用于避免网络拥塞和保持网络的稳定性。通过动态调整发送速率和拥塞窗口大小,TCP可以适应网络的负载情况,并避免过多的数据包堆积在网络中。
4:高效的流量控制:TCP使用滑动窗口机制进行流量控制,控制发送方的发送速率,确保接收方可以及时接收和处理数据。滑动窗口的大小会根据网络情况和接收方的处理能力进行动态调整。
5:面向字节流:TCP将应用层传递给它的数据视为一连串的字节流,不保留边界信息。TCP会将数据划分为适当的数据段进行传输,接收方会按照发送方发送的顺序重新组装数据。
UDP:是一种无连接的、不可靠的传输层协议。与TCP不同,UDP不提供数据传输的可靠性和顺序性,它更加注重传输的效率和速度 。
1:无连接性:UDP在发送数据之前不需要建立连接,也不需要进行握手过程。发送方可以直接将数据报文发送给接收方,接收方也可以直接接收数据报文。每个数据报文都是独立的,没有前后关联。
2:不可靠性:UDP不提供数据传输的可靠性保证。它没有确认机制和重传机制,发送方发送数据后不会接收接收方的确认,也不会进行数据的重传。如果在传输过程中发生丢包或错误,UDP不会进行任何处理,数据将丢失或损坏。
3:低延迟:由于UDP没有连接的建立和确认过程,数据可以直接发送,因此具有较低的传输延迟。这使得UDP适用于对实时性要求较高的应用,如实时音频和视频传输、在线游戏等。
4:简单性和轻量级:相比TCP,UDP的协议头部较小,占用的网络带宽较少。UDP的设计较为简单,协议处理的开销较小,适用于对网络资源要求较低的场景。
5:支持单播、多播和广播:UDP可以进行单播(一对一通信)、多播(一对多通信)和广播(一对所有通信)。它可以将数据报文同时发送给多个接收方,提供了灵活的通信方式。
21:TCP连接三次握手介绍
TCP的连接是从客户端发起连接开始,然后到服务端的响应和最后客户端的确认,所以是三次通信。
1:客户端发送一个带有 SYN(同步)标志的数据包到服务器,表示请求建立连接
报文头:
源端口:发送方的端口号
目标端口:接收方的端口号
序列号:发送方的初始序列号,用于标识发送的数据段
标志位:
SYN=1:表示这是一个建立连接的请求报文段
ACK=0:表示确认号无效
2:服务器收到请求后,发送一个带有 SYN-ACK(同步-确认)标志的数据包作为响应,确认客户端的请求
报文头:
源端口:接收方的端口号
目标端口:发送方的端口号
序列号:接收方的初始序列号,用于标识接收的数据段
确认号:发送方的序列号加1,表示确认收到了发送方的请求报文段
标志位:
SYN=1:表示这是一个建立连接的应答报文段
ACK=1:表示确认号有效
3:客户端收到服务器的响应后,发送一个带有 ACK(确认)标志的数据包,确认服务器的响应,建立连接
报文头:
源端口:发送方的端口号
目标端口:接收方的端口号
序列号:发送方的序列号加1,表示确认收到了接收方的应答报文段
确认号:接收方的序列号加1,表示确认收到了接收方的应答报文段
标志位:
ACK=1:表示确认号有效
22:TCP连接断开四次挥手介绍
TCP在断开的时候,客户端和服务端都可以发起断开请求,并且在断开后双方都要确认断开连接。所以需要四次通信 。
1:当客户端或服务器端需要关闭连接时,发送一个带有 FIN(结束)标志的数据包,表示请求关闭连接
报文头:
源端口:发送方的端口号
目标端口:接收方的端口号
序列号:发送方的最后一个数据段的序列号
标志位:
FIN=1:表示发送方希望关闭连接
ACK=0:表示确认号无效
2:接收到关闭请求的一方发送一个 ACK 数据包作为确认。
报文头:
源端口:接收方的端口号
目标端口:发送方的端口号
序列号:接收方的序列号加1,表示确认收到了发送方的结束报文段
确认号:发送方的序列号加1,表示确认收到了发送方的结束报文段
标志位:
ACK=1:表示确认号有效
3:同时接收到关闭请求的一方进入 TIME_WAIT 状态,并等待一段时间(等待可能丢失的 ACK 数据包到达),在等待时间结束后,发送一个带有 FIN 标志的数据包,表示同意关闭连接。
报文头:
源端口:接收方的端口号
目标端口:发送方的端口号
序列号:接收方的最后一个数据段的序列号
标志位:
FIN=1:表示接收方希望关闭连接
ACK=0:表示确认号无效
4:接收到关闭请求的一方再次发送一个 ACK 数据包作为确认,双方的连接关闭
报文头:
源端口:发送方的端口号
目标端口:接收方的端口号
序列号:发送方的序列号加1,表示确认收到了接收方的结束报文段
确认号:接收方的序列号加1,表示确认收到了接收方的结束报文段
标志位:
ACK=1:表示确认号有效
23:tcp粘包是怎么产生的
-
发送端需要等缓冲区满才发送。如 TCP 协议默认使用 Nagle 算法可能会把多个数据包一次发送到接收方
-
接收方不及时接收缓冲区的包,造成多个包接收。如应用程读取缓存中的数据包的速度小于接收数据包的速度,缓存中的多个数据包会被应用程序当成一个包一次读取
24:Java中常见的输入输出流
-
FileInputStream-FileOutputStream 文件数据读写
-
ObjectInputStream-ObjectOutputStream 对象数据读写
-
ByteArrayInputStream-ByteArrayOutputStream 内存字节数组读写
-
PipedInputStream-PipedOutputStream 管道输入输出
-
FilterInputStream-FilterOutputStream 过滤输入输出数据流
-
InputStreamReader-OutputStreamWriter 字节流转字符流
-
FileReader-FileWriter 文件字符输入输出流
-
BufferedReader-BufferedWriter 带缓冲的字符输入输出流
25:Java中的Socket是什么
在Java中,Socket(套接字)是一种用于实现网络通信的编程接口。它提供了一种机制,通过该机制可以在不同的计算机之间进行数据传输和通信。Socket可用于建立客户端和服务器之间的网络连接,并在这些连接上进行数据的发送和接收。
使用java.net包提供的Socket类来实现Socket编程。通过Socket类,可以创建客户端Socket和服务器端Socket,从而建立网络连接。客户端Socket用于向服务器发起连接请求,服务器端Socket则用于接受客户端的连接请求。
在Java中,Socket是基于TCP协议实现的,它提供了可靠的、面向连接的、双向的字节流传输。通过Socket编程,可以实现各种网络应用,如客户端-服务器应用、网络通信协议的实现、文件传输、远程控制等。
26:MQTT是那一层的协议,为什么在嵌入式设备上主流
MQTT(Message Queuing Telemetry Transport)是一种应用层协议,通常运行在TCP/IP协议栈上。它被设计用于在低带宽和不稳定网络环境中进行可靠的消息传递。MQTT协议被广泛应用于嵌入式设备和物联网(IoT)领域,主要有以下几个原因:
轻量级:MQTT协议非常轻量级,协议头部开销小,消息传输效率高,适用于资源受限的嵌入式设备,如传感器、智能家居设备等。
低功耗:MQTT协议设计考虑了嵌入式设备的功耗限制,它使用的是异步通信模式,设备可以在需要的时候才连接到服务器,有效地减少了设备的能耗。
可靠性:MQTT协议支持消息的持久化传输和QoS(Quality of Service)等级控制。通过QoS级别的选择,可以确保消息的可靠传递,保证设备与服务器之间的通信稳定性。
灵活性:MQTT协议支持发布-订阅模式,设备可以订阅感兴趣的主题(Topic),并接收相关消息。这种模式非常适合物联网中设备间的实时通信和数据交换
27:TCP/IP协议栈共有几层
-
网络接口层(Network Interface Layer):也称为物理层,负责处理与物理网络介质的通信,如网卡、光纤等。
-
网络层(Internet Layer):主要负责网络间的数据传输和路由选择,其中最著名的协议是IP(Internet Protocol)协议。
-
传输层(Transport Layer):提供端到端的数据传输,确保可靠的数据传输,主要使用的协议是TCP(Transmission Control Protocol)和UDP(User Datagram Protocol)。
-
应用层(Application Layer):为用户提供各种应用服务,包括HTTP、FTP、SMTP等,它是用户与网络通信的接口
28:HTTP和HTTPS有什么区别
-
安全性:HTTP是明文协议,数据在传输过程中不加密,因此容易受到网络攻击和窃听。而HTTPS通过使用SSL(Secure Sockets Layer)或TLS(Transport Layer Security)协议对数据进行加密和身份验证,确保数据传输的安全性和完整性
-
端口号:HTTP使用默认的端口号80进行通信,而HTTPS使用默认的端口号443
-
证书:HTTPS使用数字证书来验证服务器的身份。证书由可信任的第三方机构颁发,用于证明服务器是可信的。这样可以防止中间人攻击和篡改数据的风险
29:SMTP 介绍一下
SMTP(Simple Mail Transfer Protocol)是用于在计算机网络上发送电子邮件的标准协议。它是一种基于文本的协议,是应用层协议的一种。用于电子邮件客户端(如Outlook、Gmail等)和邮件服务器之间进行通信。SMTP定义了电子邮件的传输规则和格式,通常使用25号端口。
30:什么是零拷贝
介绍零拷贝之前先说一说传统数据传输,在传统的数据传输中,发生了 4 次用户态与内核态的上下文切换,以及了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的。
第一次拷贝:把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
第二次拷贝:把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
第三次拷贝:把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
第四次拷贝:把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
零拷贝数据传输,零拷贝主要是用来解决操作系统在处理 I/O 操作时,频繁复制数据的问题。关于零拷贝主要技术有 mmap+write、sendfile和splice等几种方式,其实零拷贝说的是在用户内存直接实现零拷贝,不管怎么样最终还是会有两次发生在内核拷贝中,一次是磁盘拷贝到内核,一次是内核拷贝到网卡。
31:java 异常有哪几种,特点是什么
Java中异常的顶层接口为Throwable,然后又有两大分支一类是Error,一类是Exception。
32:什么是Java中的异常
是指在程序执行过程中可能发生的错误、异常情况或意外事件。当程序运行过程中出现异常时,会抛出一个异常对象,该对象包含有关异常的信息,如异常类型、消息、堆栈跟踪等。
33:error和exception有什么区别
Error和Exception都是继承于 Throwable 类,在Java中只有 Throwable 类才可以被抛出(throw)或者捕捉(try/catch)。下面是两者的主要区别:
-
Error是不可预料的,这种异常发生之后,是不可恢复的,就比如,内存溢出错误(OutOfMemoryError)、类未定义错误(NoClassDefFoundError)。
-
Exception是可预料的,可以用try/catch捕捉到这种异常,然后抛出或者记录到日志中
34:什么是异常链
是指将一个异常作为另一个异常的原因(cause)传递的机制。当一个异常被捕获并重新抛出时,可以将原始异常作为新异常的cause进行传递,形成异常链。这样可以将异常的详细信息传递给上层调用者,提供更全面的异常信息,方便调试和定位问题。
35:try-catch-finally-return执行顺序
-
如果不发生异常,不会执行catch部分。
-
不管有没有发生异常,finally都会执行到。
-
即使try和catch中有return时,finally仍然会执行
-
finally是在return后面的表达式运算完后再执行的。(此时并没有返回运算后的值,而是先把要返回的值保存起来,若finally中无return,则不管finally中的代码怎么样,返回的值都不会改变,仍然是之前保存的值),该情况下函数返回值是在finally执行前确定的)
-
finally部分就不要return了,要不然,就回不去try或者catch的return了
36:Java异常类的重要方法是什么
-
getMessage会返回Throwable的 detailMessage属性,而 detailMessage就表示发生异常的详细消息描述
-
getLocalizedMessage:throwable的本地化描述。子类可以重写此方法,以生成特定于语言环境的消息。对于不覆盖此方法的子类,默认实现返回与相同的结果 getMessage()
-
getCause:返回此可抛出事件的原因,或者,如果原因不存在或未知,返回null
-
printStackTrace:该方法将堆栈跟踪信息打印到标准错误流
37:异常处理方式有哪些
-
抛出异常:throw关键字作用是抛出一个 Throwable类型的异常,它一般出现在函数体中
-
声明抛出异常:当你定义了一个方法时,可以用 throws关键字声明。使用了 throws关键字表明,该方法不处理异常,而是把异常留给它的调用者处理。 注意:非检查异常(Error、RuntimeException 或它们的子类)不可使用 throws 关键字来声明要抛出的异常
-
捕获异常:一般使用try-catch-finally来捕获异常并进行异常处理
38:并行是什么意思,并发又是什么意思
并行(Parallelism):指的是同时执行多个任务或操作,通过同时使用多个处理单元(例如多核处理器或分布式系统)来提高系统的性能和效率。在并行执行中,多个任务可以在同一时刻进行处理,彼此之间相互独立,不会相互干扰或阻塞。并行通常用于处理大规模计算或需要同时进行多个独立任务的情况。通过利用多个处理单元,可以将工作负载分配给不同的处理器并同时进行处理,从而加快整体的处理速度。
并发(Concurrency):指的是在同一时间段内同时处理多个任务或操作,而不是严格的同时执行。在并发执行中,多个任务交替执行,它们共享系统资源,并以一种看似同时但实际上是交替执行的方式运行。并发通常用于提高系统的吞吐量和资源利用率,以便能够同时处理多个任务或请求。通过任务切换和调度,系统可以在不同任务之间进行快速切换,给用户带来一种并行执行的感觉。
39:什么是线程 什么是进程
进程是指在计算机中运行的程序的实例。它是程序的执行过程,包含了程序的代码、数据和执行环境等信息。每个进程都拥有独立的内存空间和系统资源,它们之间相互隔离,操作系统进行管理和调度基本单元。
线程是进程中的一个执行单元,是进程内的一个独立控制流。一个进程可以包含多个线程,它们共享进程的资源和上下文,可以并发执行。线程是 CPU 调度的基本单位,它可以独立执行代码,具有独立的程序计数器、栈和局部变量等。多个线程之间可以协作、通信和共享数据,通过线程间的切换实现并发执行。
40:Java中创建线程的方式有哪些
-
继承Thread类:可以创建一个继承自Thread类的子类,并重写其run()方法来定义线程执行的任务。然后通过创建子类的实例并调用start()方法启动线程。
-
实现Runnable接口:可以创建一个实现了Runnable接口的类,并实现其run()方法来定义线程执行的任务。然后通过创建该类的实例,将其作为参数传递给Thread类的构造方法,并调用start()方法启动线程。
-
实现 Callable 接口,使用 FutureTask 类创建线程,重写call方法然后将FutureTask对象传递给Thread类的构造方法,并调用start()方法启动线程。
-
使用线程池创建、启动线程,将Thread对象提交到线程池中进行维护
41:什么是并发编程
并发编程是指在程序中使用多个执行线程来处理任务的编程方式。在并发编程中,多个任务可以同时执行,相互之间可以并行或交替执行,以提高程序的性能和效率。
42:如何优雅地停止一个线程
使用标志位:在线程的执行代码中使用一个标志位,当标志位为true时线程继续执行,当标志位为false时线程退出循环或执行完成,从而停止线程的执行。这种方式需要线程主动检查标志位,并在适当的地方进行判断和处理。
private volatile boolean running = true;
public void run() {
while (running) {
// 执行任务逻辑
}
}
public void stopThread() {
running = false;
}
使用Thread.interrupt()方法:调用线程的interrupt()方法可以发送中断信号给线程,通过检查线程的中断状态,线程可以自行决定是否终止执行。通常,线程会在合适的地方检查中断状态,并根据需要进行相应的处理。
public void run() {
while (!Thread.currentThread().isInterrupted()) {
// 执行任务逻辑
}
}
public void stopThread() {
thread.interrupt();
}
43:Java线程的生命周期中各种状态
-
新建(New)状态:当程序使用new关键字创建了一个线程之后,该线程就处于 新建状态。
-
就绪(Runnable)状态:当线程对象调用了start()方法之后,该线程处于 就绪状态。
-
运行(Running)状态:当CPU开始调度处于 就绪状态 的线程时,此时线程获得了CPU时间片才得以真正开始执行run()方法的线程执行体,则该线程处于 运行状态。
-
阻塞(Blocked)状态:处于运行状态的线程在某些情况下,让出CPU并暂时停止自己的运行,进入 阻塞状态。
-
死亡(Dead)状态:线程执行完毕或者发生异常或者调用终止方法等操作结束线程的执行
44:Java中创建线程池的方式有哪些
使用Executors工具类:Executors类提供了一系列静态方法来创建不同类型的线程池newFixedThreadPool(int nThreads): 创建固定大小的线程池,该线程池中的线程数量固定为指定的值。当有新的任务提交时,如果当前线程池中的线程都在执行任务,则新任务会等待直到有可用的线程。
ExecutorService executor = Executors.newFixedThreadPool(5); // 创建固定大小的线程池
-
newCachedThreadPool(): 创建一个可缓存的线程池,线程池的大小根据需要进行调整。当有新的任务提交时,如果当前线程池中有空闲的线程,则会重用这些线程来执行任务。如果没有可用的空闲线程,则会创建新的线程。线程空闲一定时间后,如果没有新的任务提交,这些线程会被终止并从线程池中移除。
-
newSingleThreadExecutor(): 创建一个单线程的线程池,线程池中只有一个线程在执行任务。所有提交的任务会按照顺序依次执行,保证任务的顺序性。
-
newScheduledThreadPool(int corePoolSize): 创建一个定时任务线程池,可以用于执行定时任务和周期性任务。该线程池可以指定核心线程数,超过核心线程数的任务会在指定的时间延迟后执行。
-
newSingleThreadScheduledExecutor(): 创建一个单线程的定时任务线程池,类似于newScheduledThreadPool(int corePoolSize),但只有一个线程用于执行定时任务。
使用ThreadPoolExecutor类:ThreadPoolExecutor类是ExecutorService接口的一个实现,可以直接使用该类来创建线程池,并自定义线程池的参数和行为。
int corePoolSize = 5;
int maxPoolSize = 10;
long keepAliveTime = 60L;
TimeUnit unit = TimeUnit.SECONDS;
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(100);
ExecutorService executor = new ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime, unit, workQueue);
使用ForkJoinPool类:ForkJoinPool类是用于执行分而治之(fork-join)任务的线程池,适用于处理递归、分治算法等需要将任务拆分为子任务并进行合并的情况。
ForkJoinPool forkJoinPool = new ForkJoinPool();
45:如何停止一个线程池
shutdown(): 调用线程池的shutdown()方法会平缓地关闭线程池。该方法会停止接受新的任务,并尝试将已提交的任务执行完毕。调用该方法后,线程池不会立即关闭,而是等待已提交的任务执行完成。已提交但尚未执行的任务将会被取消。如果线程池中的线程处于空闲状态,它们会继续保持等待任务的状态,直到所有任务执行完毕。
shutdownNow(): 调用线程池的shutdownNow()方法会立即关闭线程池。该方法会尝试停止所有正在执行的任务,并返回尚未执行的任务列表。调用该方法后,线程池会立即停止接受新的任务,并尝试中断正在执行的任务。然后,它会返回尚未执行的任务列表,这些任务可能会包括已提交但尚未开始执行的任务
46:synchronized关键字的作用是什么,原理又是什么
synchronized关键字用于实现线程之间的同步,确保在多线程环境下对共享资源的访问是安全的,synchronized的作用有两个方面:
1:保证原子性:当一个线程获得了对象的锁,执行synchronized修饰的代码块时,其他线程需要等待锁释放才能执行相同的代码块。这样可以保证在同一时刻只有一个线程执行该代码块,从而保证了对共享资源的操作的原子性。
2:保证可见性:当一个线程释放了对象的锁,它对共享变量所做的修改对于其他线程是可见的。也就是说,一个线程对共享变量的修改在释放锁之后,对其他线程是可见的。
synchronized原理是:
synchronized关键字的原理是基于对象的监视器锁(monitor lock)来实现的。每个Java对象都有一个与之相关联的监视器锁,它可以被用来实现对对象的同步访问。当线程进入synchronized修饰的代码块时,它会尝试获取对象的监视器锁。如果锁是空闲的,线程将获得锁并继续执行代码块中的操作;如果锁已经被其他线程持有,线程将被阻塞,直到锁被释放。只有持有锁的线程才能执行synchronized代码块,其他线程需要等待锁的释放。
synchronized关键字可以用于修饰实例方法、静态方法和代码块。对于实例方法和静态方法,锁对象分别是该实例对象和该类的Class对象;对于代码块,可以指定任意的对象作为锁对象。
47:volatile关键字的作用是什么,原理又是什么
volatile关键字用于保证共享变量的可见性和禁止指令重排序。volatile的作用有两个方面:
1:可见性:当一个线程对一个volatile变量进行写操作时,这个操作将立即被刷新到主内存中,同时强制其他线程从主内存中重新读取该变量的值。这样可以确保多个线程之间对该变量的读写操作是可见的,避免了使用线程本地缓存而导致的数据不一致性问题。
2:禁止指令重排序:编译器和处理器在进行指令优化时,可能会对指令进行重排序。使用volatile关键字修饰的变量会禁止编译器和处理器对其进行重排序,保证指令执行的顺序与代码的顺序一致。
volatile原理是:
volatile关键字的原理是通过内存屏障(Memory Barrier)来实现的。内存屏障是一种硬件或者指令级别的机制,它确保在屏障之前的所有操作都完成后,再执行屏障之后的操作。对于写操作,内存屏障会将该操作刷新到主内存;对于读操作,内存屏障会使处理器从主内存中重新加载变量的值。这样可以保证在多线程环境下对volatile变量的读写操作是按照顺序执行的。
需要注意的是,volatile关键字只能保证单个变量的可见性和禁止指令重排序,并不能替代synchronized关键字提供的原子性。如果涉及到复合操作或者需要保证原子性的操作,仍然需要使用synchronized关键字或者其他更强大的同步机制。
48:Java中有哪些锁
49:介绍一下CAS和AQS两种机制
CAS(比较并交换):CAS是一种无锁的原子操作,用于实现多线程并发控制。它通过比较内存中的值与期望值是否相等来决定是否进行更新,从而避免了使用锁带来的线程阻塞和上下文切换的开销。CAS操作通常涉及三个操作数:内存地址(或偏移量)、期望值和新值。如果当前值与期望值相等,则使用新值更新内存中的值;否则,CAS操作失败,需要重试。在Java中CAS主要是使用Unsafe类中定义。Java中的java.util.concurrent.atomic包提供了一系列基于CAS操作的原子类,如AtomicInteger、AtomicLong等。
AQS(抽象队列同步器):AQS是Java并发包中提供的一个用于构建锁和同步器的框架。它提供了一种灵活的方式来实现各种同步机制,如独占锁、共享锁、信号量等。AQS的核心思想是使用一个整数表示同步状态,并通过CAS操作来进行状态的获取和释放。AQS内部维护了一个等待队列,线程在获取同步状态失败时会进入等待队列,当状态可用时再被唤醒。通过继承AQS类并实现其中的抽象方法,开发者可以相对简单地构建自定义的同步组件。Java中的ReentrantLock、CountDownLatch、Semaphore等同步器都是基于AQS实现的。
50:synchronized锁的升级原理是什么
锁分级别原因:在JDK 6 以前,synchronized 是重量级锁(悲观锁),使用 wait 和 notify、notifyAll 来切换线程状态非常消耗系统资源;线程的挂起和唤醒间隔很短暂,这样很浪费资源,影响性能。所以在JDK6之后 JVM 对 synchronized 关键字进行了优化,把锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。
synchronized锁的升级过程可以分为以下几个阶段:
-
无锁状态(无竞争):当没有线程请求获取锁时,对象处于无锁状态。
-
偏向锁状态(轻量级锁):当只有一个线程请求获取锁时,对象会被标记为偏向锁,并且记录持有锁的线程ID。此时,获取锁操作会变得非常轻量级,只需检查标记位和线程ID是否匹配即可。
-
轻量级锁状态:当多个线程同时请求获取锁时,对象会膨胀为轻量级锁。此时,JVM会使用CAS(Compare and Swap)操作尝试获取锁,如果成功则顺利获取锁;如果失败,说明存在竞争,锁会升级为重量级锁。
-
重量级锁状态:当多个线程竞争获取锁失败后,锁会膨胀为重量级锁。此时,线程会进入阻塞状态,进入锁等待队列,通过操作系统的同步原语来实现线程的挂起和唤醒。
锁的升级过程主要是为了在无竞争情况下减少锁的开销,提高程序的性能。通过将锁从偏向锁升级到轻量级锁再到重量级锁,可以根据实际的竞争情况来选择合适的锁实现,从而在无竞争时避免不必要的同步开销。
需要注意的是,锁的升级是自动进行的,由JVM在运行时根据线程竞争情况来动态调整。在实际开发中,我们通常无需显式地干预锁的升级过程,只需要合理设计同步代码块和同步方法的范围和粒度,让JVM自动进行锁的升级和降级。
51:Java中如何避免死锁
-
使用 Lock 的 tryLock(long timeout, TimeUnit unit)的方法,设置超时时间,超时可以退出防止死锁。
-
尽量使用并发工具类代替加锁。
-
尽量降低锁的使用粒度。
-
尽量减少同步的代码块。
52:Java ThreadLocal介绍一下 并且说一下原理
ThreadLocal是Java中的一个类,用于在多线程环境下为每个线程提供独立的变量副本。使得每个线程都可以独立地操作自己的变量副本,而不会受到其他线程的干扰。ThreadLocal主要用于解决多线程环境下的共享变量访问的线程安全问题。
53:什么是 happens-before 原则
"happens-before"原则是Java内存模型(Java Memory Model,JMM)中的一个重要概念,用于定义多线程环境下的操作执行顺序和可见性,happens-before"原则规定了在单线程或多线程环境下,某个操作的结果对于其他操作的可见性和顺序的影响。具体来说,如果操作A "happens-before"操作B,那么操作A的结果对于操作B来说是可见的,且操作A在操作B之前执行。
54:sleep()和wait()有什么区别
-
sleep() 是 Thread 类的静态本地方法;wait() 是Object类的成员本地方法。
-
sleep() 方法可以在任何地方使用;wait() 方法则只能在同步方法或同步代码块中使用。
-
sleep() 会休眠当前线程指定时间,释放 CPU 资源,不释放对象锁,休眠时间到自动苏醒继续执行;wait() 方法放弃持有的对象锁,进入等待队列,当该对象被调用 notify() / notifyAll() 方法后才有机会竞争获取对象锁,进入运行状态。
55:线程池中submit()和execute()方法有什么区别
1:execute() 参数 Runnable,submit() 参数 Runnable或Callable。
2:execute() 没有返回值,而 submit() 有返回值,submit() 的返回值 Future 调用get方法时,可以捕获处理异常。
56:介绍一下CountDownLatch
CountDownLatch是Java中的一个同步辅助类,它用于在多线程环境中控制线程的执行顺序和等待其他线程完成。
CountDownLatch的工作原理很简单:它初始化一个给定的计数器,表示需要等待的线程数量。每个线程在完成自己的任务后,通过调用countDown()方法将计数器减1。当计数器的值变为0时,处于等待状态的线程将被释放,可以继续执行。
57:说一说Java的内存模型
Java的内存模型定义了Java程序中多个线程之间如何访问共享内存并进行通信的规范。它涉及到线程之间的可见性、有序性和原子性等方面的规则,确保多线程环境下的内存访问安全性。
Java内存模型(Java Memory Model,JMM)具有以下特点:
-
主内存(Main Memory):主内存是所有线程共享的内存区域,包含所有的实例对象、静态变量和数组等数据。
-
线程工作内存(Thread Working Memory):每个线程都有自己的工作内存,存储了线程运行时所需的数据。线程工作内存中存储了主内存中的部分数据副本。
-
内存屏障(Memory Barriers):内存屏障是一种同步操作,用于确保特定的内存访问顺序。它可以控制不同线程之间的可见性和有序性。在Java中,内存屏障通过synchronized、volatile和Lock等关键字实现。
-
可见性(Visibility):可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到修改后的值。通过volatile关键字或合适的锁机制可以实现可见性。
-
有序性(Ordering):有序性指的是程序执行的顺序,保证了指令的有序执行。在Java中,通过内存屏障和volatile关键字可以实现有序性。
-
原子性(Atomicity):原子性指的是一个操作要么完全执行成功,要么完全不执行。Java提供了一些原子性操作,如原子类(Atomic Classes)和锁机制,用于实现线程安全的原子操作。
58:说一说JDK8 JVM的内存模型
-
程序计数器:记录线程的字节码指令的地址,方便线程切换后能够恢复到正确的执行位置(线程私有)
-
本地方法栈:用于执行本地方法(Native Method)的内存区域(线程私有)
-
虚拟机栈:用于存储方法调用的局部变量、方法参数和调用方法的执行环境。每个方法在执行时都会创建一个栈帧,栈帧包含了方法的局部变量表、操作数栈、动态链接等信息(线程私有)
-
堆(Heap):堆是Java程序运行时对象和字符串常量池的存储区域。它被所有线程共享,用于存储对象实例和数组。在Java 8中,堆被划分为年轻代、年老代(线程公有)
-
元数据区:元数据区是用于存储类的元数据信息和运行时常量池,例如类的结构信息、方法信息、字段信息等。它不再位于Java堆中,而是直接分配在本地内存(Native Memory)中,因此不再受到Java堆大小的限制(线程公有)
59:JDK8为什么要使用元空间取代永久代
-
内存管理的灵活性:永久代的大小是固定的,无法根据应用程序的需求进行动态调整。而元空间则可以根据需要自动调整大小,并且它的内存分配和释放由操作系统直接管理,不再依赖于垃圾回收器
-
避免永久代内存溢出:在永久代中,存放着类的元数据信息、字符串常量池等。当应用程序动态加载大量的类或者创建大量的字符串常量时,容易导致永久代内存溢出。而元空间不再有固定的大小限制,可以根据实际情况分配更多的内存空间
-
优化类的卸载:在永久代中,当一个类被加载到内存中后,即使它不再被使用,也无法被垃圾回收机制回收,除非整个永久代满了才会触发Full GC。这导致了类的卸载困难。而元空间采用了基于使用量的垃圾回收算法,当一个类不再被引用时,可以更及时地回收其占用的内存空间
-
去除永久代的限制:永久代存在一些限制,例如字符串常量池的容量有限,对动态生成的类和匿名类的支持不够灵活等。使用元空间后,这些限制得到了解除,可以更好地支持动态生成类和提供更大的字符串常量池容量
60:对象回收判断方式有哪些
引用计数算法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题。
缺点:难以检测出对象之间的循环引用。同时,引用计数器增加了程序执行的开销。所以Java语言并没有选择这种算法进行垃圾回收。
可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。
哪些能作为GC Root:
(1)虚拟机栈中引用的对象(栈帧中的本地变量表);
(2)方法区中的常量引用的对象;
(3)方法区中的类静态属性引用的对象;
(4)本地方法栈中JNI(Native方法)的引用对象。
(5)活跃线程
61:有哪些垃圾回收算法
标记-清除算法(Mark and Sweep):该算法分为两个阶段,首先标记出所有活动对象,然后清除未被标记的对象。这种算法的主要问题是内存碎片的产生。
复制算法(Copying):该算法将可用内存划分为两个相等的区域,每次只使用其中的一部分。当一部分的内存被占满后,将活动对象复制到另一部分的空闲内存中,然后清除已使用的内存。这种算法解决了内存碎片的问题,但会浪费一部分内存。
标记-整理算法(Mark and Compact):该算法首先标记出所有活动对象,然后将活动对象压缩到内存的一端,然后清除未被压缩的内存。这种算法消除了内存碎片,并且不会浪费内存,但执行时间较长。
分代算法(Generational):该算法基于一种观察:大多数对象具有短暂的生命周期,而只有少数对象具有长生命周期。根据这个观察,内存可以划分为不同的代,并针对不同代使用不同的垃圾回收算法。一般将内存分为新生代(Young Generation)和老年代(Old Generation),新生代使用复制算法,老年代使用标记-压缩算法
62:有哪些垃圾回收器
Serial 垃圾回收器:该回收器使用单线程进行垃圾回收,会暂停应用程序的执行。适用于小型或单线程应用,具有较低的延迟和内存占用。该垃圾回收器在新生代采用的是复制算法,在老年代采用的是标记整理算法 通过JVM参数-XX:+UseSerialGC可以使用串行垃圾回收器。
Parallel 垃圾回收器:该回收器使用多个线程并行进行垃圾回收,可以显著缩短垃圾回收的时间。适用于多核处理器和需要高吞吐量的应用。新生代使用复制算法,在老年代使用标记-清除-整理算法(在Java8中默认使用的垃圾回收器) 通过JVM参数XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。
CMS(Concurrent Mark Sweep)垃圾回收器:该回收器采用并发标记清除算法,允许垃圾回收和应用程序同时执行。适用于对延迟有较高要求的应用,但可能导致较低的吞吐量。该垃圾回收器采用的是标记整理算法 通过JVM参数 XX:+USeParNewGC 打开并发标记扫描垃圾回收器。
G1(Garbage First)垃圾回收器:该回收器是一种基于分代的垃圾回收器,采用了分区的内存管理方式。它可以在不同区域执行垃圾回收,以实现更可控的停顿时间和更好的吞吐量。适用于大内存应用和对延迟和吞吐量都有较高要求的应用。该垃圾回收器采用分代收集算法 通过JVM参数-XX:+UseG1GC 可以开启G1垃圾回收器 。
63:Java中引用类型有哪些
强引用(Strong Reference):最常见的引用类型,使用关键字 new 创建的对象默认就是强引用。只要强引用存在,垃圾回收器就不会回收该对象。
软引用(Soft Reference):用于描述还有用但非必需的对象。当系统内存不足时,垃圾回收器可能会回收软引用对象来释放内存。可以使用 SoftReference 类来创建软引用。
弱引用(Weak Reference):用于描述非必需对象。与软引用不同,弱引用更容易被垃圾回收器回收。可以使用 WeakReference 类来创建弱引用。
虚引用(Phantom Reference):用于描述一个对象的生命周期结束。虚引用无法通过引用来获取对象的实例,主要用于跟踪对象被垃圾回收的过程。可以使用 PhantomReference 类来创建虚引用。
64:JVM中常用的命令有哪些
java:用于启动Java应用程序。
javac:用于将Java源代码编译为字节码文件。
javap:用于反编译字节码文件,显示Java类的反汇编信息。
jps:用于列出正在运行的Java进程的进程ID。
jstat:用于监视Java应用程序的各种统计信息,如垃圾收集、类加载等。
jmap:用于生成Java堆转储快照,以分析内存使用情况。
jstack:用于生成Java线程转储快照,以分析线程状态和死锁等问题。
jconsole:用于图形化地监视和管理Java应用程序的工具。
jvisualvm:用于图形化地监视和分析Java应用程序的工具,提供了丰富的插件和功能。
jcmd:用于向正在运行的Java进程发送诊断命令,如线程转储、堆转储等。
jinfo:用于查看和修改Java进程的配置信息,如系统属性、JVM参数等。
jrunscript:用于在命令行中执行JavaScript脚本。
jshell:用于在命令行中执行Java代码片段,提供了交互式的编程环境
65:谈谈对 OOM 的认识
除了程序计数器,其他内存区域都有 OOM 的风险。
栈一般经常会发生 StackOverflowError。栈发生 OOM 的场景如 32 位的 windows 系统单进程限制 2G 内存,无限创建线程就会发生栈的 OOM。
Java 8 常量池移到堆中,溢出会出 java.lang.OutOfMemoryError: Java heap space,设置最大元空间大小参数无效。
堆内存溢出,报错同上,这种比较好理解,GC 之后无法在堆中申请内存创建对象就会报错。
方法区 OOM,经常会遇到的是动态生成大量的类、jsp 等。
直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请
66:什么情况发生栈溢出
-Xss可以设置线程栈的大小,当线程方法递归调用层次太深或者栈帧中的局部变量过多时,会出现栈溢出错误 java.lang.StackOverflowError。
67:如何排查 OOM 的问题
增加两个参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,当 OOM 发生时自动 dump 堆内存信息到指定目录,同时 jstat 查看监控 JVM 的内存和 GC 情况,先观察问题大概出在什么区。使用 MAT 工具载入到 dump 文件,分析大对象的占用情况,比如 HashMap 做缓存未清理,时间长了就会内存溢出,可以把改为弱引用。
68:JVM的监控和分析工具有哪些
MAT,Memory Analyzer Tool,虚拟机内存分析工具
vjtools,唯品会的包含核心类库与问题分析工具
arthas,阿里开源的 Java 诊断工具
greys,JVM进程执行过程中的异常诊断工具
GCHisto,GC 分析工具
GCViewer,GC 日志文件分析工具
GCeasy,在线版 GC 日志文件分析工具
JProfiler,检查、监控、追踪 Java 性能的工具
BTrace,基于动态字节码修改技术(Hotswap)实现的Java程序追踪与分析工具
69:如何找到死锁的线程
jstack -F -l <pid> > threads.txt,如果存在死锁,一般在文件最后会提示找到 deadlock 的数量与线程信息。
70:什么是逃逸分析
逃逸分析(Escape Analysis)是一种编译器优化技术,用于确定对象的生命周期及其对外部作用域的影响。逃逸分析的目标是识别那些在方法内部创建的对象是否会被方法外部引用或者线程共享,从而确定是否可以在栈上分配对象或者进行其他优化。
逃逸分析的原理是通过静态分析和动态分析的方法来确定对象的生命周期。在静态分析阶段,编译器会通过对程序的控制流和数据流的分析来估计对象的作用域。如果编译器能够确定对象只在方法内部使用,并且不会逃逸到方法外部,就可以将对象分配在栈上而不是堆上,从而避免了堆内存的分配和垃圾回收的开销。在动态分析阶段,编译器会在运行时收集对象的使用信息,进一步优化对象的分配和使用。
逃逸分析的好处包括:
减少堆内存分配:将对象分配在栈上可以减少堆内存的分配和垃圾回收的开销,提高程序的性能。
支持标量替换:逃逸分析可以确定对象的字段是否可以被拆解为独立的标量值,并将其分配在栈上或者寄存器中,进一步提高程序的执行效率。
优化锁粒度:逃逸分析可以帮助编译器确定对象的锁粒度,从而进行锁消除或锁粗化等优化,提高并发性能
71:可以描述一下 class 文件的结构吗
-
魔数(Magic Number):4 个字节的无符号数,用于标识文件是否为有效的 class 文件。它的值固定为 0xCAFEBABE
-
版本信息:2 个无符号数,分别表示编译器的主版本号和次版本号。例如,Java 8 的版本号是 52.0
-
常量池(Constant Pool):紧随版本信息后是常量池表,包含了各种常量的信息,如字符串、类、字段、方法等。常量池中的每个常量都有一个标签(Tag)来表示其类型
-
访问标志(Access Flags):用于描述类或接口的访问权限和属性,如是否是 public、final、abstract 等
-
类和父类信息:指向该类的全限定名和父类的全限定名
-
接口信息:指示该类实现的接口列表
-
字段信息:描述类中声明的字段,包括字段的修饰符、名称、类型等
-
方法信息:描述类中声明的方法,包括方法的修饰符、名称、参数列表、返回类型等
-
属性信息:描述类或方法的附加信息,如注解、异常表、源码行号等