- 线程基础
- 并发安全
- 线程池
- 使用场景题
问题:线程和进程的区别?
程序:指令+数据 进程的作用:加载指令、管理内存、管理IO
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时候就开启了一个进程
而对于线程:
一个线程就是一个指令流,将指令流中的一条条指令按照一定的顺序给CPU执行 一个进程之内可以分为一个到多个线程
注意这几个关键词:内存空间、切换成本
问题:并行和并发的区别?
单核CPU下,线程实际上还是串行化执行的 也就是说:微观上串行,宏观上并行 一般来说,这种线程轮流使用CPU的方式称为并发
而对于多核CPU而言:
于是:
并发是同一时间应对多件事情的能力(前后依次做) 并行是同一时间动手做多件事情的能力(同一时刻展开,多线作战)
- 继承Thread类
- 实现runnable接口
- 实现Callable接口
- 线程池创建线程
public class MyThread extends Thread {
@Override
public void run() {
}
public static void main(String[] args) {
t1.start();
}
}这边是把Runnable接口进行implements实现
问题:Runnable和callable有什么区别?
回答如下:
- Runnable 接口 run 方法没有返回值
- Callable 接口 call 方法有返回值,是个泛型;
- Callable 接口的call() 方法允许抛出异常,而 Runnable 接口的 run() 方法的异常只能在内部消化,不能继续向上抛出
问题:run()和start()的区别?
回答如下:
run()可以开启多次,而start()只能被调用一次,因为其是用来启动一次的,并且调用了run方法执行封装了的功能
public enum State {
New,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED
}这边线程的状态中,就绪+运行组成了可执行(RUNNABLE)状态
如下这张状态转换图很清晰:
问题一:线程包含哪些状态?
回答如下: New 新建 RUNNABLE 可运行 BLOCKED 阻塞 WAITING 等待 TIMED_WAITING 时间等待(出现这种状态一般是线程调了个sleep(50)之类的方法) TERMINATED 终止(执行完了线程,over!!!)
问题二:线程状态之间是如何变化的?
tips:重点结合上面那张图进行理解。
可以使用线程中的join方法解决
join() 等待线程运行结束
但这边其实还有很多别的办法,详见leetcode 1115
对于 t.join(), 其实是阻塞调用此方法的线程进入time_waiting,直到线程t执行完毕后,再运行当前线程
- notifyAll: 唤醒所有wait的线程
- notify:只随机唤醒一个wait线程
共同点:
wait(), wait(long), sleep(long) 的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态
不同点:
方法归属不同
- sleep(long) 是 Thread 的静态方法
- wait(), wait(long) 是Object的成员方法,每个对象都有
醒来时机不同
- wait(long)和wait()会被notify唤醒,wait()如果不唤醒就一直等待
锁特性不同
- 使用退出标志,使得线程正常退出,也就是run方法完成后,线程终止(比如加个volatile)
- 使用stop方法强行终止(但这个办法已经作废了)
- 调用interrupt方法中断线程
- 打断阻塞的线程(sleep, wait, join),线程会抛出InterruptedException的异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
底层是Monitor
Monitor,即监视器,由JVM提供,c++实现
问题:synchronized关键字的底层原理?
回答如下:
- Synchronized(对象锁)采用互斥的方式,让同一时刻最多只有一个线程能持有对象锁
- 其底层由Monitor实现,Monitor是JVM级别的对象(C++)实现,线程获得锁需要使用对象(锁)关联Monitor
- 在Monitor内部有三个属性:owner、entryList、waitSet
- 其中:owner是关联的获得锁的线程,并且只能关联一个线程;entryList关联的是出于阻塞状态的线程;waitSet关联的是出于Waiting状态的线程
Monitor实现的锁是重量级锁,而这带来了一个话题,也就是锁升级
- Monitor实现的锁属于重量级锁,其中涉及到了用户态和内核态的切换,进程上下文的切换。成本高、性价比低
- 在jdk1.6引入了两种新型锁机制:偏向锁和轻量级锁,其引入是为了解决在没有多线程竞争或者基本没有竞争的场景下,使用传统锁机制带来的性能开销问题
采用了MarkWord的内存结构
由于每个java对象都可以关联一个Monitor对象,如果使用synchronized把对象上锁(重量级)之后,该对象头的mark word中,就设定了指向 Monitor 对象的指针
关于Monitor重量级锁:
关于轻量级锁:
关于偏向锁:
问题:Monitor实现的锁是重量级锁,你了解锁升级吗?
回答如下:
- JMM(Java Memory Model) Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作,从而保证指令的正确性
- JMM将内存分为两块,一块是私有线程的工作区域(工作内存),另一块是所有线程的共享区域(主内存)
- 线程和线程之间是相互隔离,线程跟线程交互需要通过主内存
这个还是非常重要的
全称:Compare And Swap(比较再交换)
体现的是乐观锁的思想,在无锁情况下,保证线程操作共享数据的原子性
提到了自旋锁
问题:CAS相关?
- CAS全称是 Compare And Swap;其体现了一种乐观锁的思想,在无锁状态下,保证线程操作数据的原子性
- CAS使用的地方很多,比如AQS框架,再比如AtomicXX类
- 在操作共享变量时采用的自旋锁,效率很高
- CAS底层调用的是Unsafe类中的方法,都是操作系统提供的,由其他语言实现
问题:关于乐观锁和悲观锁的区别?
回答如下:
- CAS 是基于乐观锁的实现:不断重试,不怕别的线程来修改共享变量
- synchronized 是基于悲观锁的思想:最悲观的估计,得防备其他线程来修改共享变量
- 保证了线程间的可见性
- 禁止指令进行重排序
- 底层实现原理:添加了一个内存屏障,通过插入内存屏障禁止在内存屏障的前后指令执行重排序优化
AQS全称:AbstractQueuedSynchronizer
这个是阻塞式锁和相关的同步器工具的框架
AQS是悲观锁,手动开启和关闭
AQS 常见的实现类:
- ReentrantLock 阻塞式锁
- Semaphore 信号量
- CountDownLatch 倒计时锁
- AQS中维护的一个使用volatile修饰的State属性,来表示资源的状态
- 提供基于FIFO的等待队列,类似Monitor的EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似Monitor中的WaitSet
多个线程进行争抢资源时,为了保证原子性,需要使用CAS提供的自旋锁。确保只有一个线程修改成功,而修改失败的线程会进入FIFO队列中进行等待
- 如果新来的线程与队列中的线程共同争抢资源,那么就是非公平锁
- 如果新来的线程在队列中等待,而只让队头的head线程获取锁,那么就是公平锁
典型的AQS实现类,ReentrantLock,就是非公平锁
ReentrantLock,意思即是可重入锁
相较于Synchronized,其具有以下特点:
- 可中断
- 可以设置超时时间
- 可以设置公平锁
- 支持多个条件变量
- 与Synchronized一样,都支持重入
ReentrantLock主要利用CAS+AQS队列实现,支持公平锁+非公平锁
工作流程是: 线程抢锁+判断State状态+判断exclusiveOwnerThread是否为null+公平/非公平锁
-
语法层面:
- Synchronized是关键词
- Lock是接口,用Java实现,需要手动调用unlock方法释放锁
-
功能层面
- 两者均是悲观锁,都具备最基本的互斥、同步、锁重入等功能
- Lock提供了许多Synchronized无法实现的功能
-
性能层面
- 没有竞争时,Synchronized性能不错,其优化包括偏向锁、轻量级锁
- 竞争激烈时,Lock的实现会提供更好的性能
死锁:一个线程同时需要获取多把锁,此时就非常容易产生死锁
比如x, y线程互相需要获取A、B对象的锁,但是其各自占据B、A对象
可以使用jps查看当前java程序运行的进程id
然后通过jstack查看当前这个进程id,即可发现死锁的问题
这是一种线程安全的高效Map集合
底层的数据结构
- jdk1.7:底层是分段的数组+链表
- jdk1.8 采用的数据结构和HashMap1.8的结构一致,数组+链表/红黑树
在jdk1.8中,采用了 CAS + Synchronized 来保证并发安全的实现
- CAS 控制数组节点的添加
- Synchronized 只锁定当前链表或者红黑树的首节点,只要Hash不冲突,就不会产生并发的问题
乐观锁认为并发冲突很少发生,而悲观锁认为并发冲突一定发生; 乐观锁是先操作数据然后校验,而悲观锁是先锁后操作; 乐观锁依赖数据的版本机制或者CAS算法,而悲观锁依赖数据库的锁机制或者编程语言的同步机制(比如Synchronized、Lock); 乐观锁适合读操作多、并发冲突少的场景,可以提高系统吞吐量。悲观锁适合写操作多、并发冲突严重的场景,能确保数据安全;
java并发编程的三大特性:
- 原子性
- 可见性
- 有序性
一个线程在CPU中的执行,是不可暂停或者中断的;所以这就带来了原子性问题
所谓的内存可见性:让一个线程对共享变量的修改对另一个线程可见
处理器为了提高程序的运行效率,会对输入代码进行优化,导致执行语句不一致。 但是某些情况下,我们需要使得指令按照特定的顺序执行,由此,我们可以使用volatile关键字来禁止指令重排
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThraedFactory threadFactory,
REjectedExecutionHandler handler
)md这个东西必须记住,之前第一次面一上来就是这个问题,答的一塌糊涂
这几个参数一一分析过去:
- corePoolSize 核心线程数量
- maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位
- workQueue:当没有空闲核心线程时,新来任务会加入到此队列进行排队,队列满时,会创建救急线程执行任务
- ThreadFactory:线程工厂。 可以定制线程对象的创建
- handler:拒绝策略,当所有的线程都在繁忙,workQueue也放满时,会触发拒绝策略
注意,如果阻塞队列没满,就会先把因为核心进程满了而多余出来的任务放到阻塞队列里去;如果阻塞队列已满,需要判断线程数(临时线程+核心线程)是否小于最大线程数
拒绝策略:
- AbortPolicy:直接抛出异常,这是默认策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列中最靠前的任务,并执行当前任务;
- DiscardPolicy:直接丢弃任务
workQueue:当没有空闲核心进程时,新来的任务会加入到该队列排队。队列满时,会创建救急线程来执行任务。
- ArrayBlockingQueue 基于数组结构的有界阻塞队列
- LinkedBlockingQueue 基于链表结构的有界阻塞队列
- DelayedWorkQueue 优先级队列
- SynchronousQueue:不存储元素的阻塞队列
ArrayBlockingQueue使用一把锁来控制对队列的访问,意味着读写操作是互斥的; LinkedBlockingQueue使用两把锁,一把控制度,另一把控制写,这样可以提高并发性能
令$N = $计算机CPU的核数
- IO密集型任务:核心线程数 =
$2 \times N + 1$ - CPU密集型任务:核心线程数 =
$N + 1$
为什么要这样设计呢?
对于CPU密集型任务,主要在执行计算。CPU使用率极高,很少有等待中的情况。那么,即使开了多余的线程,CPU也跑不过来。那开它有什么用?而且开多了造成线程间的切换,反而会浪费CPU资源。
对于IO密集型任务,主要是在等待中。比如说查询数据库,读写文件,请求网络接口HTTP/RPC。那么,我们需要比较多的线程,去充分利用处于等待状态的CPU。举个例子,A线程在查询数据库,这时候会闲等。这时候A线程发起通知,执行B线程。从而充分的利用CPU,提高并发度。
在JUC中提供了大量创建线程池的静态方法,常见的如下:
- 创建使用固定线程数的线程池:也就是说 corePoolSize = maximunPoolSize
- 没有救急线程
- 阻塞队列是LinkedBlockingQueue,最大容量是Integer.MAX_VALUE
- 单线程化的线程池
- 也就是说,它只会用唯一的工作线程来执行任务,保证所有任务都按照指定顺序(FIFO)执行
- corePoolSize = maximunPoolSize = 1
- 阻塞队列是LinkedBlockingQueue
- 适用于按顺序执行的任务
- 可缓存线程池
- 核心线程数 = 0
- 最大线程数是INT_MAX
- 阻塞队列为SynchronousQueue:这个阻塞队列不存储元素,而是每个插入操作都必须等待一个移除操作
- 适用于任务数量很密集,但是每个人物执行时间很短的情形
- 提供“延迟”和“周期执行”功能的ThreadPoolExecutor
- 适用于有定时和延迟执行的任务
这是因为其请求队列workQueue的最大长度是INT_MAX,这会导致堆积大量的请求,最终导致OOM(内存溢出)
所以一般用ThreadPoolExecutor去创建线程池,从而指定线程池的参数,避免资源被耗尽
jdk中提供了一个Semaphore类,其提供了两个方法。 semaphore.acquire()表示请求信号量,可以限制线程的个数。 semaphore.release()代表释放一个信号量
ThreadLocal是多线程中,为了解决线程安全的一个操作类,其为每个线程都分配了一个独立的线程副本,从而解决了变量并发访问冲突的问题。
ThreadLocal同时实现了线程内的资源共享 (此处的资源共享指的是同一个线程内,多个方法或者组件可以共享同一个变量副本)
ThreadLocal本质上说,是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现自身线程数据隔离
ThreadLocal有一个内部类叫做ThreadLocalMap,类似于HashMap
ThreadLocalMap中有一个属性Table数组,这个才是真正存储数据的位置
java对象中,四种引用类型: 强引用、软引用、弱引用 、虚引用
Java中的分布式锁(Distributed Lock)主要是为了解决在分布式系统下,多个独立的Java虚拟机(JVM)进程或者多个服务器节点之间,对共享资源进行访问控制和同步的问题
使用 Redis 实现分布式锁,是当前最常见而且高性能的方案之一。
在分布式系统中,多个服务实例(比如说部署在不同服务器上面的多个订单服务),可能会同时尝试修改同一个共享资源(比如说数据库里面的一条库存记录)。这个时候,为了保证数据的一致性和正确性,我们需要一种机制来确保在任意时刻,只有一个进程可以操作这个共享资源。
而分布式锁,就是实现这种跨JVM互斥访问的机制
Redis具有天然的原子性操作,使得其非常适合作为锁服务,最基础的实现思路是利用 Redis 的 SET 命令,以及其参数,去实现 互斥 和 超时释放
一个严谨的基于Redis的分布式锁的加锁和解锁的流程,通常包括以下这三个步骤:
- 加锁(Acquire Lock):使用一个原子命令(比如 SET KEY unique_value EX expire_time NX)
- 操作共享资源:只有加锁成功的进程才能执行业务逻辑
- 释放锁(Release Lock):为了保证释放锁的安全性和原子性,必须使用Lua脚本。关于Lua脚本的逻辑:先检查存储在KEY中的unique_value是否与当前客户端持有的值相同(以证明锁是自己加的),再删除KEY
- 为什么必须要用 Lua 脚本?
如果不用lua脚本,而是先GET检查,再DEL删除,这两个步骤之间如果发生网络延迟或者线程切换,锁可能过期并且被其他线程持有,导致当前进程误删了别人的锁,造成并发问题。Lua脚本保证了检查和删除的原子执行!
一般来说,我们不会自己去写那些Redis命令和Lua脚本,而是使用成熟的客户端框架,最常用的是Redisson
关于Redisson的底层实现机制,其采用了看门狗机制去解决锁提前过期问题
- 看门狗机制:如果进程成功加锁,但是没有设置一个特定的锁释放时间(LeaseTime),Redisson会启动一个守护线程。这个线程会每隔一段时间默认是过期时间的1/3),去自动检查锁是否还被持有。如果是,那么自动续期(去延长过期时间),直到锁被正式释放,这大大提高了锁的可靠性。
对于Process进程,Thread线程,coroutine协程,这三者我们可以建立一个抽象的模型。
- 进程:工厂车间
- 线程:车间里的工人
- 协程:工人工作自己创造出的一套方法论
对于进程:
首先是概念,进程是操作系统分配资源的最小单位。每个进程都被分配了独立的内存空间,就像每个车间都被工厂分配了独立的厂房。进程之间的通信,需要专门的通道IPC。
对于线程:
概念:线程是CPU调度的基本单位。
正如工厂里一个车间内至少有一位工人,一个进程内部,也会有至少一个线程(主线程)。同一个进程内的线程共享内存,如果线程崩溃,也会导致进程崩溃。操作系统内核起到了调度室的作用,去调度多个线程的并发执行。
对于协程:
概念:是用户自定义的轻量级线程,由程序自己控制调度
协程无需调度室(OS内核)去进行调度切换任务,而是自行决定执行顺序和关闭时机。切换任务的开销非常小,速度极快。
这里借用Gemini的见解:
举个例子,我运行了一个爬虫项目:
python spider.py其中,进程的作用:启动和隔离。运行这个爬虫后,操作系统创建了一个进程。OS为这个爬虫分配了一块内存,加载了代码,并且打开了日志文件。
线程的作用:爬虫需要去下载网页,然后解析网页的内容。在进程的内部,我们创建了多个线程。线程A去执行下载HTML,线程B去执行提取数据的任务。多个线程并发执行,让CPU不再闲置。
协程的作用:高并发,并且对抗IO等待。创建1万个线程,会导致CPU崩溃。但是创建1万个协程,占用的内存很少,而且程序自己快速切换,能够轻松的实现单机上万级的并发。
进程之间如何通信?
进程是独立的资源单位,互相看不见对方的内存。如果要传递数据,需要专门的通信机制,也就是所谓的IPC(Inter-Process Communication)
借用Gemini的整理:
线程之间是如何通信的?
由于所有的线程之间都共享同一个进程的内存空间(堆内存+全局变量),所以线程之间的通信机制比进程Process要简单的多。同时,也危险的多。
核心机制是共享内存,也就是Shared Memory,而通信的手段有多种多样,线程的通信重点不在于“怎么传输数据”,而是“如何安全地传输数据”,下面是几个策略:
- 锁机制(Locks/Synchronized)
- volatile关键字
- 等待/通信机制(Wait/Notify)
- 并发数据结构:这个其实挺有意思的,比如BlockingQueue,也就是阻塞队列。
新的一个问题自然而然的出现了,协程之间是如何进行通信的?
协程是在同一个线程中跑的,共享内存是自然的事情。不过如果用锁,会非常麻烦。所以,我们通常采用Channel,也叫做管道
Channel用于传输数据,一般默认是阻塞的。如果没有缓冲,发送方就会挂起,直到接收方拿走数据。

















