目录
- 基础
- 进程线程:star:
- 创建线程的几种方式:star: Runnable和Callable的区别?
- 用过future吗?关于Future和FutureTask区别?
- 线程的通信有几种方法,主机到主机的通信方式:star:
- 线程安全是什么,怎么实现?
- juc包下的类了解哪些
- 分布式锁怎么实现
- 并行与并发
- 多线程好处
- 并发编程的三重要特性
- 什么是上下文切换
- 线程死锁(死锁条件):star:
- 哪些方法使线程等待?什么方法能停止线程?
- 说说sleep()方法和wait()方法区别和共同点,wait()为什么要在同步代码块或同步方法里面
- 为什么我们调用start()方法时会执行run()方法,为什么我们不能直接调用run()方法
- 线程状态有哪些:star:
- 线程执行流程
- 什么情况下线程会阻塞
- synchronized:star:
- 讲一下JMM(Java内存模型)
- Volatile
- 乐观锁与悲观锁
- ThreadLocal
- ConcurrentHashMap
- 实践
参考:https://snailclimb.gitee.io/javaguide/#/?id=%e5%b9%b6%e5%8f%91
基础
进程线程⭐️
- 进程:运行程序的基本单位
- 线程:线程是一个比进程更小的执行单位,一个进程可以有多个线程
- 两者的关系:多个线程共享进程的堆和方法区,每个线程有自己的程序计数器、虚拟机栈和本地方法栈
创建线程的几种方式⭐️ Runnable和Callable的区别?
继承Thread类、实现Runnable接口、使用Callable和Future创建线程、使用线程池例如用Executor框架
Runnable:提供run方法无返回值,所有异常必须在run方法内部处理,可以通过Thread的构造参数开启新的线程,也可以用线程池创建
Callable:提供call方法提供返回值用来表示任务运行的结果,可以直接抛出Exception异常,只能通过线程池执行
用过future吗?关于Future和FutureTask区别?
Future模式:相对于普通模式只是发起了耗时操作,函数立马就返回了,并不会阻塞客户端线程
使用ExecutorService来创建一个Future:ExecutorService中定义的一个submit方法,它接收一个Callable参数(实现一个call方法并返回一个结果),并返回一个Future。
Future是个接口,FutureTask是Future的具体实现,而且FutureTask还间接实现了Runnable接口,也就是说FutureTask可以作为Runnable任务提交给线程池
线程的通信有几种方法,主机到主机的通信方式⭐️
通知等待模式
管道
Exchange
ThreadLocal
单播、广播、组播
线程安全是什么,怎么实现?
当多个线程访问一个对象时,调用这个对象的行为都可以获得正确的结果
使用 synchronized 关键字来获取锁
juc包下的类了解哪些
分布式锁怎么实现
并行与并发
- 并行: 单位时间内,多个任务同时执行
- 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行)
多线程好处
线程间的切换和调度成本远远小于进程,是开发高并发系统的基础,可以大大提高系统整体的并发能力以及性能。
带来问题:内存泄漏、死锁、线程不安全等
并发编程的三重要特性
- 原子性 :要么所有的操作都执行,要么都不执行,synchronized 可以保证代码片段的原子性
- 可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值
- 有序性 :代码在执行的过程中有先后顺序,编译器优化可能会改变其顺序,volatile 关键字可以禁止指令进行重排序优化
什么是上下文切换
当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用
线程死锁(死锁条件)⭐️
四个条件:
- 互斥条件:资源只由一个线程占用
- 循环等待条件:多个进程之间形成一种头尾相接的循环等待资源关系
- 请求与保持条件:请求资源阻塞时,对已获得的资源保持不放
- 不剥夺条件:现有的资源在末使用完之前不能被其他线程强行剥夺
避免死锁(破坏四个中的一个即可)
-
破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)
-
破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件
-
破坏请求与保持条件 :一次性申请所有的资源
-
破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
哪些方法使线程等待?什么方法能停止线程?
join、wait、sleep
run() 方法完成后线程中止
使用 interrupt 方法
使用 stop() 方法强行终止线程(该方法已被弃用)
说说sleep()方法和wait()方法区别和共同点,wait()为什么要在同步代码块或同步方法里面
- 区别:sleep 方法没有释放锁,wait 方法释放了锁
Wait 常用于线程间交互/通信,需要调用notify() 或者 notifyAll() 方法才会苏醒
sleep 常用于暂停执行,自动苏醒 - 共同点:两者都可以暂停线程的执行
这是Java设计者为了避免使用者出现lost wake up问题而搞出来的
为什么我们调用start()方法时会执行run()方法,为什么我们不能直接调用run()方法
调用 start 方法方可启动线程并使线程进入就绪状态
run 方法只是 thread 的一个普通方法调用,还是在主线程里执行
线程状态有哪些⭐️
下面 6 种不同状态的其中一个状态
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
(原图中 wait到 runnable状态的转换中,join
实际上是Thread
类的方法,但这里写成了Object
)
线程执行流程
新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。
什么情况下线程会阻塞
阻塞的情况分三种:
- 等待阻塞(o.wait->等待对列):运行的线程执行 o.wait()方法,该线程进入等待队列(waitting queue) 中。
- 同步阻塞(lock->锁池):获取对象的同步锁时,若该同步锁被别的线程占用,则 该线程进入锁池中。
- 其他阻塞(sleep/join):运行的线程执行 Thread.sleep(long ms)或 t.join()方法
synchronized⭐️
说说对他的了解
解决多个线程之间访问资源的同步性:可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行(属于 重量级锁)
怎么使用
synchronized 关键字最主要的三种使用方式:
- 修饰实例方法: 作用于当前对象实例加锁
- 修饰静态方法: 给当前类加锁,会作用于类的所有对象实例
- 修饰代码块 :指定加锁对象,对给定对象/类加锁
对象头(Monitor)
每个对象都有对象头,主要由Mark Work和KclassWork组成
分为Mark Work和KclassWork两部分
Mark Work主要包含
- hashcode:自己的哈希码
- age:分代年龄
- biased_lock:是否为偏向锁
- 锁状态 001:无锁 101:偏向锁 00:轻量级锁 10: 重量级锁 11: GC标记
Kclass找到对应的类对象
讲一下synchronized 的底层原理
synchronized 同步语句块:基于进入和退出Monitor对象来实现方法同步和代码块同步。在代码同步的开始位置织入monitorenter,在结束同步的位置(正常结束和异常结束处)织入monitorexit指令实现。
synchronized 修饰的方法:ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。
两者的本质都是对对象监视器 monitor 的获取。
JDK1.6之后的synchronized关键字底层做了哪些优化
增加了偏向锁、轻量级锁、自旋锁、锁消除、锁粗化等技术
对锁机制的了解⭐️
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现
这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随
着竞争的激烈而逐渐升级
刚开始是无锁状态,当加载第一个线程时就将此线程 ID 设置到对象的 Mark Word 头,偏向锁改为1,下次判断还是此id则不需CAS,当有竞争时锁膨胀成轻量级锁进行自旋操作,如果自旋不成功,再升级成重量级锁
谈谈synchronized和ReentrantLock的区别⭐️
都是可重入锁(同一个线程获取锁)
如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住)
ReentrantLock增加了一些高级功能
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量
synchronized依赖于JVM而ReentrantLock依赖于API(API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
说下可重入锁原理
每个锁关联一个线程持有者和一个计数器,当一个线程拿到锁,计数器会加一,其他线程来时只能等待,而这个线程再次来时只需要计数器加一即可
讲一下JMM(Java内存模型)
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、
CPU 指令优化等。
JMM 体现在以下几个方面
原子性 - 保证指令不会受到线程上下文切换的影响
可见性 - 保证指令不会受 cpu 缓存的影响
有序性 - 保证指令不会受 cpu 指令并行优化的影响
从主存(即共享内存)读取变量
线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写
这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致
用变量volatile声明:这个变量是共享且不稳定的,每次使用它都到主存中进行读取
Volatile
对Volatile的了解⭐️
有一个写屏障和读屏障
可见性:写指令后会加入写屏障(保证在该屏障之前的,都同步到主存当中),读指令前会加入读屏障(保证在该屏障之后,读取的都是主存中的值)
有序性:不会将写屏障之前的代码排在写屏障之后(不后写),不会将读屏障之后的代码排在读屏障之前(不先读)
注意
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
而有序性的保证也只是保证了本线程内相关代码不被重排序
和synchronized的区别
- volatile:不能保证线程安全,能保证可见性和有序性,轻量级的,只能修饰于变量,主要用于解决变量在多个线程之间的可见性
- synchronized:能保证可见性、有序性、原子性,重量级的,可以修饰方法以及代码块,解决多个线程之间访问资源的同步性
什么是指令重排?会带来什么样的后果?怎样防止?⭐️
为了使处理器内部的运算单元能尽量被充分利用,处理器可
能会对代码进行优化,保证代码执行的结果是一致的,但代码的先后顺序可能会改变
如果存在一个计算任务依赖另外一个计算任务的中间结果,那么这个计算结果可能会不对
加volatile或者synchronized关键字
乐观锁与悲观锁
- 乐观锁:总是假设最好的情况,每次去读数据的时候都认为别人不会修改,所以不会上锁,写的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。适用于多读的应用类型
- 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
使用场景⭐️
乐观锁适用于读多写少
悲观锁适用于写多
乐观锁常见的两种实现方式(CAS是什么)⭐️
版本号机制:在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
CAS算法实现:即compare and swap(比较与交换),也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。当要更新一个数时,先记录这个数,回来再比较一下这个数,如果一样则成功,不一样则失败
乐观锁缺点
-
ABA 问题:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。
解决:JDK 1.5 以后的 AtomicStampedReference 增加了一个stamp版本号, compareAndSet 会判断要修改的值和版本号是否都没有改变,如果全部相等,则更新成功。 -
循环时间长开销大:自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。
解决:如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
-
只能保证一个共享变量的原子操作:CAS 只对单个共享变量有效
解决:但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
ThreadLocal
是什么
线程本地变量,起到线程隔离的作用,避免了线程安全问题
原理
Thread类有一个类型为ThreadLocalMap的实例变量threadLocals,每个线程都有一个属于自己的ThreadLocalMap
ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值
每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离,key与ThreadLocal时弱引用,如果下一次回收没有其他强引用这个Entry就会被回收
ThreadLocal内存结构图:
TreadLocal的引用示意图
内存泄露问题
当一个对象已经不需要再使用本该被回收时,另外一个正在使用的对象持有它的引用从而导致它不能被回收,这导致本该被回收的对象不能被回收而停留在堆内存中,这就产生了内存泄漏。
弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存
如何解决:使用完ThreadLocal后,及时调用remove()方法释放内存空间
应用场景
数据库连接池、会话管理中使用
ConcurrentHashMap
ConcurrentHashMap:JDK1.7 层采用,每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率,JDK1.8 采用数组+链表/红黑二叉树,。
JDK1.7 的 ConcurrentHashMap:分段的数组+链表实现,对整个桶数组进行了分割分段(Segment)
JDK1.8 的 ConcurrentHashMap:摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作