面试题
约 2357 字大约 8 分钟
2026-04-04
原文链接:Java基础面试16问
说说进程和线程的区别?
进程是程序的一次执行,是系统资源分配与调动的基本单位,使程序能够并发执行
因为进程的创建、销毁、切换产生大量的时间和空间开销,进程的数量不能太多,线程是比进程更小的能独立运行的基本单位,是进程的一个实体
线程基本不占用系统资源,只运行必不可少的资源(程序计数器,寄存器,栈)
进程占用堆、栈
知道synchronized原理吗?
java提供的原子性内置锁🔒,内置的使用者看不到的锁称为监视器锁,使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和
monitorexit字节码指令,它依赖操作系统底层互斥锁实现。它的作用主要是实现原子性操作和解决共享变量的内存可见性问题。
执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1.此时其他竞争锁的线程会进入等待队列中。
执行monitorexit指令时会把计数器-1,当计数器为0时,锁释放,处于等待队列中的线程再继续竞争锁。
synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁。
对于加锁,那再说下ReentrantLock原理?他和synchronized有什么区别?
ReentrantLock需要显式的获取锁和释放锁
等待可中断,当持有锁的线程长时间不释放锁的时候,等待中的线程可以选择放弃等待,转而处理其他的任务。
公平锁:
synchronized和ReentrantLock默认都是非公平锁,但是ReentrantLock可以通过构造函数传参改变。只不过使用公平锁的话会导致性能急剧下降。public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }绑定多个条件:ReentrantLock可以同时绑定多个Condition条件对象。
java并发控制:ReentrantLock Condition使用详解-阿里云开发者社区 (aliyun.com)
package com.Test;
import org.junit.Test;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @Description TODO
* @Date 2021/10/28 22:43
* @Created by 折腾的小飞
*/
public class ReentrantLockDemo {
/**
* description: 我们要打印1到9这9个数字,由A线程先打印1,2,3,然后由B线程打印4,5,6,
* 然后再由A线程打印7,8,9. 这道题有很多种解法,
* 现在我们使用Condition来做这道题(使用Object的wait,notify方法的解法在这里)。
*
* @since: 1.0.0
* @author: 涂鏊飞tu_aofei@163.com
* @date: 2021/10/28 22:49
* @return: void
*/
static class NumberWrapper {
public int value = 1;
}
/*
初始化可重入锁
*/
final Lock lock = new ReentrantLock();
@Test
public void Demo() {
//第一个条件当屏幕上输出到3
final Condition reachThreeCondition = lock.newCondition();
//第二个条件当屏幕上输出到6
final Condition reachSixCondition = lock.newCondition();
//NumberWrapper只是为了封装一个数字,一边可以将数字对象共享,并可以设置为final
//注意这里不要用Integer, Integer 是不可变对象
final NumberWrapper num = new NumberWrapper();
//初始化A线程
Thread threadA= new Thread(new Runnable() {
public void run() {
//需要先获得锁
lock.lock();
try {
System.out.println("threadA start write");
//A线程先输出前3个数
while (num.value <= 3) {
System.out.println(num.value);
num.value++;
}
//输出到3时要signal,告诉B线程可以开始了
reachThreeCondition.signal();
}finally {
lock.unlock();
}
lock.lock();
try{
//等待输出6的条件
reachSixCondition.await();
System.out.println("threadA start write");
//输出剩余数字
while (num.value <= 9) {
System.out.println(num.value);
num.value++;
}
}catch (InterruptedException e) {
e.printStackTrace();
} finally {
}
}
});
Thread threadB = new Thread(new Runnable() {
public void run() {
try {
lock.lock();
while (num.value <= 3) {
//等待3输出完毕的信号
reachThreeCondition.await();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
try {
lock.lock();
//已经收到信号,开始输出4,5,6
System.out.println("threadB start write");
while (num.value <= 6) {
System.out.println(num.value);
num.value++;
}
//4,5,6输出完毕,告诉A线程6输出完了
reachSixCondition.signal();
} finally {
lock.unlock();
}
}
});
threadA.start();
threadB.start();
}
}
好,说说HashMap原理吧?
HashMap主要由数组和链表组成,存储键值对,不是线程安全的。
插入数据用put,get查询数据以及扩容的方式。
jdk1.7和1.8的主要区别在于头插和尾插方式的修改,头插容易导致HashMap链表死循环,并且1.8后加入红黑树性能有提升。
https://blog.csdn.net/littlehaes/article/details/105241194
put插入数据流程
向map插入数据时获得key的hash与数组长度-1进行与运算【(n-1)&hash】。找到数组中的位置后,如果数组中没有元素,则直接存入,有元素则判断key是否相同,相同则覆盖,不相同则插入到链表的尾部,如果链表长度超过8并且数据长度超过64,则会转换成红黑树(CRUD操作的时间复杂度都为O(log n)),最后判断数组长度是否超过默认长度(16)* 负载因子(0.75),超过则进行扩容。
get查询数据
首先计算hash值,去数组查询,是红黑树就去红黑树查,链表就遍历链表查询
resize扩容过程
对key重新计算hash,把数据拷贝到新的数组
那多线程环境怎么使用Map呢?ConcurrentHashmap了解过吗?
可使用Collections.synchronizedMap同步加锁和hashTable,ConcurrentHashmap更适合高并发场景。
ConcurrentHashmap在JDK1.7和1.8的版本改动比较大,1.7使用Segment+HashEntry分段锁的方式实现,1.8则抛弃了Segment,改为使用CAS+synchronized+Node实现,同样也加入了红黑树,避免链表过长导致性能的问题。
1.7分段锁
1.7版本的ConcurrentHashMap采用分段锁机制,里面包含一个Segment数组,Segment继承与ReentrantLock,Segment则包含HashEntry的数组,HashEntry本事是一个链表的结构,具有保存key、value的能力能指向下一个节点的指针。
实际上每个Segment都是一个HashMap,默认的长度是16,支持16个线程的并发写,Segment之间相互不影响。
put流程
定位到具体的Segment,然后通过ReentrantLock去操作
- 计算hash,定位到segment,segment如果是空就先初始化
- 使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功
- 遍历HashEntry,就是和HashMap一样,数组中key和hash一样就直接替换,不存在就再插入链表,链表同样
get流程
key通过hash定位到segment,再遍历链表定位到具体的元素上,需要注意的是value是volatile的,所以get是不需要加锁的。
1.8 CAS+synchronized
put流程
- 首先计算hash,遍历node数组,如果node是空的话,就通过CAS+自旋的方式初始化
- 如果当前数组位置是空则直接通过CAS自旋写入数据
- 如果hash==MOVED,说明需要扩容,执行扩容
- 如果都不满足,就使用synchronized写入数据,写入数据同样判断链表、红黑树,链表写入和HashMap的方式一样,key hash一样就覆盖,反之就尾插法,链表长度超过8就转换成红黑树
get查询
通过key计算hash,如果key hash相同就返回,如果是红黑树按照红黑树获取,都不是就遍历链表获取。
1.7扩容
单独扩容segment数组,不影响其它的segment
infexFor计算元素的下标
1.8扩容
整体扩容,CAS多线程扩容,渐进式扩容
(n-1)& hashcode计算元素的下标
1.7 size()
三次获取sgment数组大小,如果3次结果不同,加锁计算数组大小
1.8 size()
维护一个数组保存节点数量
volatile原理知道吗?
相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,他没有上下文切换的额外开销成本。使用volatile声明的变量,可以确保值被更新的时候对其他线程立刻可见。volatile使用内存屏障来保证不会发生指令重排,解决了内存可见性的问题。
线程池原理知道吗?
最大线程数maximumPoolSize
核心线程数corePoolSize
活跃时间keepAliveTime
阻塞队列workQueue
拒绝策略RejectedExecutionHandler
当提交一个新任务到线程池时,具体的执行流程如下:
- 当我们提交任务,线程池会根据corePoolSize大小创建若干任务数量线程执行任务
- 当任务的数量超过corePoolSize数量,后续的任务将会进入阻塞队列阻塞排队
- 当阻塞队列也满了之后,那么将会继续创建(maximumPoolSize-corePoolSize)个数量的线程来执行任务,如果任务处理完成,maximumPoolSize-corePoolSize额外创建的线程等待keepAliveTime之后被自动销毁
- 如果达到maximumPoolSize,阻塞队列还是满的状态,那么将根据不同的拒绝策略对应处理
拒绝策略有哪些?
- AbortPolicy:直接丢弃任务,抛出异常,这是默认策略
- CallerRunsPolicy:只用调用者所在的线程来处理任务
- DiscardOldestPolicy:丢弃等待队列中最旧的任务,并执行当前任务
- DiscardPolicy:直接丢弃任务,也不抛出异常
贡献者
更新日志
fb8bc-更新为vuepress于