目录
- 线程有哪些状态
- 线程池的核心参数
- sleep和wait的区别
- lock 与 synchronized 的异同
- volatile能否保证线程安全
- 悲观锁和乐观锁的区别
- Hashtable 与 ConcurrentHashMap 的区别
- ConcurrentHashMap1.7和1.8的区别
- ThreadLocal的理解
- ThreadLocalMap中的key为何要设置为弱引用
线程有哪些状态
Java线程分为六种状态,分别是新建,可运行,终结,阻塞,等待,有时限等待
前面三个新建,运行,终结都是单向不可逆的,而后面的阻塞,等待,有时限等待是可以与运行状态来回变换的
线程池的核心参数
线程池的核心参数一共有7个
核心线程数
,是指一直保留在线程池里的线程数目最大线程数
,是指核心线程数和救急线程数的总和生存时间
,是指救急线程的存活时间,当救急线程空闲时间达到这个生存时间后,就会终结时间单位
,是指存活时间的单位,是秒还是分钟或者小时阻塞队列
,当核心线程用完后,剩下任务就在阻塞队列中排队,这里是指阻塞队列的长度线程工厂
,给线程池创建新线程
最后一个是拒绝策略,拒绝策略有四种,如下
当任务数量超过核心线程数量,最大排队数量以及临时线程数量的总和,全负载时,多余任务会根据线程池的拒绝策略来丢弃或处理
sleep和wait的区别
一个共同点,三个不同点
共同点
wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
-
方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
-
醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
-
锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
(这里简单可以理解,wait释放锁睡觉,sleep抱着锁睡觉)
lock 与 synchronized 的异同
可以从三个层面分解
不同点
①语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
②功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入(可以加多道锁)功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态(哪些线程被阻塞了)、公平锁、可打断、可超时(可设置最大超时时间,超过指定时间放弃获取锁)、多条件变量
- Lock 有适合不同场景的实现,如 ReentrantLock(可重入锁), ReentrantReadWriteLock(读多写少场景)
③性能层面
- 在没有竞争时(或竞争很少时),synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
Lock锁,是用创建的锁对象调用lock(),unlock()方法,上锁和释放锁
synchronized ,是同步代码块或同步方方法的方式
公平锁
- 公平锁的公平体现
- 已经处在阻塞队列中的线程(不考虑超时)始终都是公平的,先进先出
- 公平锁是指未处于阻塞队列中的线程来争抢锁,如果队列不为空,则老实到队尾等待
- 非公平锁是指未处于阻塞队列中的线程来争抢锁,与队列头唤醒的线程去竞争,谁抢到算谁的
- 公平锁会降低吞吐量,一般不用
条件变量
- ReentrantLock 中的条件变量功能类似于普通 synchronized 的 wait,notify,用在当线程获得锁后,发现条件不满足时,临时等待的链表结构
- 与 synchronized 的等待集合不同之处在于,ReentrantLock 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制
volatile能否保证线程安全
线程安全要考虑三个方面:可见性、有序性、原子性
①可见性指,一个线程对共享变量修改,另一个线程能看到最新的结果
②有序性指,一个线程内代码按编写顺序执行
③原子性指,一个线程内多行代码以一个整体运行,期间不能有其它线程的代码插队
原子性
- 起因:多线程下,不同线程的指令发生了交错导致的共享变量的读写混乱
- 解决:用悲观锁或乐观锁解决,volatile 并不能解决原子性
可见性
- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致的对共享变量所做的修改另外的线程看不到
- 解决:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
下面就是一个典型的永远循环-可见性案例
那到底是什么原因导致了线程0修改stop值后,主线程仍然继续循环,而线程1获取的stop又显示为true呢?
其中就是JIT的起的作用,JIT是优化器,对热点的字节码文件进行优化,例如频繁调用的方法,反复执行的循环;
在线程0还没有改变stop的值时,主线程在线程0睡眠的100毫秒内,已经执行了几百万次了,这时优化器JIT坐不住了,就对这个反复读取的stop且每次读取值都相同的字节码做优化,避免它频繁从内存中读取,把stop认为是false,把它的字节码编译成机器码缓存起来,再次循环时,就直接拿这个机器码,节省中间解释过程。如下图
若上面那个stop变量用了volatile修饰,JIT就不会对其优化,放过它
有序性
- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致指令的实际执行顺序与编写顺序不一致
- 解决:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
- 注意:
- volatile 变量写加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下
- volatile 变量读加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上
- volatile 读写加入的屏障只能防止同一线程内的指令重排
写是上面操作不能越过下面(避免被程序之前的数据覆盖),读是下面操作不能越过上面(避免读取的是程序后面的数据)
是不能逆着箭头排的
悲观锁和乐观锁的区别
对比悲观锁与乐观锁
-
悲观锁的代表是 synchronized 和 Lock 锁
- 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
- 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
- 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
-
乐观锁的代表是 AtomicInteger,使用 cas (compareAndSetInt方法,比较并交换的缩写)来保证原子性
- 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
- 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
- 它需要多核 cpu 支持,且线程数不应超过 cpu 核数
cas方法是从乐观的角度出发,假设每次获取数据别人都不会修改,所以不会上锁。只不过在修改共享数据的时候,会检查一下,把传入的旧值和当前共享变量的最新值作比较,来判断别人有没有修改过这个数据。
如果别人修改过,那么我再次获取现在最新的值。
如果别人没有修改过,那么我现在直接修改共享数据的值.(乐观锁)
CAS是要和volatile一起使用,这样才能保证每次看到的共享变量是最新值
synchronized 加锁是使用的互斥方式,将多行代码作为一个整体执行,保证并发下的原子性
而CAS没有互斥,是并发执行,只是在修改操作时,用比较并交换的原则,来判断传入的旧值和当前获取的最新值是否一致(看看你要更新的值有没有被人修改过),一致才修改成功,不一致则修改失败,修改失败也无所谓,因为乐观锁是不断重试直至成功,在while(true)循环中,再拿到新值,在最新值得基础上做更新操作
下面是代码演示
synchronized 悲观锁
CAS 乐观锁
Hashtable 与 ConcurrentHashMap 的区别
Hashtable 对比 ConcurrentHashMap
- Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
- Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
- ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突
(Hashtable的底层实现是数组+链表结构实现的;hashtable的容量是质数,有较好的分布性,不需要进行二次哈希)
ConcurrentHashMap1.7和1.8的区别
ConcurrentHashMap 1.7
- 数据结构:
Segment(大数组) + HashEntry(小数组) + 链表
,每个 Segment 对应一把锁,如果多个线程访问不同的 Segment,则不会冲突 - 并发度:Segment 数组大小即并发度,决定了同一时刻最多能有多少个线程并发访问。Segment 数组不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了
- 索引计算
- 假设大数组长度是 2 m 2^m 2m,key 在大数组内的索引是 key 的二次 hash 值的高 m 位(例如:capacity为32,32是2的5次方,取二次hash值高5位,就是前5位,转十进制就得到存储位置索引值)
- 假设小数组长度是 2 n 2^n 2n,key 在小数组内的索引是 key 的二次 hash 值的低 n 位
- 扩容:每个小数组的扩容相对独立,小数组在超过扩容因子时会触发扩容,每次扩容翻倍;由于hashtable是加锁了的,即使链表使用头插法,也不会像ConcurrentHashMap那样导致死链
- Segment[0] 原型:首次创建其它小数组时,会以此原型为依据,数组长度,扩容因子都会以原型为准,下图作为演示
新的小数组会根据当前segment[0]做为原型来创建 - 小数组容量最小是2;小数组容量 = 大数组容量 / 可并发数
下面来个图,有图有真相
并发度决定蓝色数组的大小,容量决定小数组entry的大小
ConcurrentHashMap 1.8
-
数据结构:
Node 数组 + 链表或红黑树
,数组的每个头节点作为锁,如果多个线程访问的头节点不同,则不会冲突。首次生成头节点时如果发生竞争,利用 cas 而非 syncronized,进一步提升性能
-
并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容
-
扩容条件:Node 数组满 3/4 时就会扩容,1.7是超出才扩容
-
扩容单位:以链表为单位从后向前迁移链表,迁移完成的将旧数组头节点替换为 ForwardingNode
-
扩容时并发 get
-
根据是否为 ForwardingNode 来决定是在新数组查找还是在旧数组查找,不会阻塞
-
如果链表长度超过 1,则需要对节点进行复制(创建新节点),怕的是节点迁移后 next 指针改变
-
如果链表最后几个元素扩容后索引不变,则节点无需复制
-
-
扩容时并发 put
- 如果 put 的线程与扩容线程操作的链表是同一个,put 线程会阻塞
- 如果 put 的线程操作的链表还未迁移完成,即头节点不是 ForwardingNode,则可以并发执行
- 如果 put 的线程操作的链表已经迁移完成,即头结点是 ForwardingNode,则可以协助扩容
-
capacity 代表预估的元素个数,capacity / factory 来计算出初始数组大小,需要贴近 2 n 2^n 2n
例如,要放16个元素(capacity为16),但是数组满扩容因子就会扩容,如果数组长度为16是不够的,所以数组容量 = capacity / factory;简单说就是capacity是数组容量的3/4.
- loadFactor 只在计算初始数组大小时被使用,之后扩容固定为 3/4
- 超过树化阈值时的扩容问题,如果容量已经是 64,直接树化,否则在原来容量基础上做 3 轮扩容
区别
①从底层结构上
1.7ConcurrentHashMap 底层数据结构是Segment(大数组) + HashEntry(小数组) + 链表
而1.8 ConcurrentHashMap 底层数据结构是 数组 + 链表或红黑树,而且链表添加元素上不同于1.7的头插法,而是尾插法
②从初始化的时机上
与 1.7 相比是懒惰初始化,1.7是饿汉式初始化,初始化以后,数组及数组零号元素已经创建出来了,1.8是在第一次put元素时,才会创建底层数组结构,是懒汉式初始化
③从扩容时机上
1.7是容量超出扩容因子才扩容
而1.8是数组满 3/4(或扩容因子*数组容量) 时就会扩容
ThreadLocal的理解
- ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
- ThreadLocal 同时实现了线程间的线程隔离和线程内(只要是同一个线程,可以在多个方法中获取同一变量的)资源共享
原理
每个线程内有一个 ThreadLocalMap 类型的集合,用来存储资源对象
- 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
- 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
- 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
线程间的资源是隔离的,key(ThreadLocal)可以相同,但关联的资源池可能不同
ThreadLocalMap
key 的 hash 值统一分配;初始容量为16;元素个数满2/3(扩容因子),数组扩容原来两倍,索引值相同时,不再使用链表那种拉链法,而是使用开放寻址法(从这索引开始,找下一个空闲位置作为索引位置)。
ThreadLocalMap中的key为何要设置为弱引用
弱引用 key
ThreadLocalMap 中的 key 被设计为弱引用,原因如下
- Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存
内存释放时机
①被动 GC 释放 key
- 仅是让 key 的内存释放,关联 value 的内存并不会释放
② 懒惰被动释放 value
- get key 时,发现是 null key,则释放其 value 内存;(当ThreadLocalMap根据key去get值时,发现key不存在,会放入一个为null的key;这是和其他map的不同之处)
- set key 时,会使用启发式扫描,清除临近的 null key 的 value 内存,启发次数与元素个数,是否发现 null key 有关
③主动 remove 释放 key,value
- 会同时释放 key,value 的内存,也会清除临近的 null key 的 value 内存
- 推荐使用它,因为一般使用 ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收
所以①和②两种方法都不可用,最好还是主动回收掉