🍊 Synchronized
Synchronized是Java高频面试题,相关的知识点其实有很多,更为详细的Synchronized知识点,我也有写,全部讲一遍,差不多要一个小时以上,有时间的同学可以去看看,这里提供地址:https://liaozhiwei.blog.csdn.net/article/details/124900072,面试官想问的主要是锁的升级过程,下面由浅到深讲解,专门针对面试题,归总的知识点列举出来。
🎉 定义
Synchronized是Java语言的关键字,它保证同一时刻被Synchronized修饰的代码最多只有1个线程执行。
🎉 应用场景
synchronized如果加在方法上/对象上,那么,它作用的对象是非静态的,它取得的锁是对象锁;
synchronized如果作用的对象是一个静态方法或一个类,它取到的锁是类锁,这个类所有的对象用的是同一把锁。
每个对象只有一个锁,谁拿到这个锁,谁就可以运行它所控制的那段代码。
🎉 对象加锁实现原理
在Java的设计中,每一个Java对象就带了一把看不见的锁,可以叫做内部锁或者Monitor锁,Synchronized在JVM里的实现是基于进入和退出Monitor对象来实现方法同步和代码块同步的。Monitor可以把它理解为一个同步工具,所有的Java对象是天生的Monitor,Monitor监视器对象就是存在于每个Java对象的对象头MarkWord里面,也就是存储指针的指向,Synchronized锁便是通过这种方式获取锁的。
📝 JDK6以前
Synchronized加锁是通过对象内部的监视器锁来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要比较长的时间。
📝 实现步骤
第一步,当有二个线程A、线程B都要开始给变量+1,要进行操作的时候,发现方法上加了Synchronized锁,这时线程调度到A线程执行,A线程就抢先拿到了锁,当前已经获取到锁资源的线程被称为Owner,将MonitorObject中的_owner设置成A线程。
第二步,将mark word设置为Monitor对象地址,锁标志位改为10;
第三步,将B线程阻塞,放到ContentionList队列中。因为JVM每次从Waiting Queue的尾部取出一个线程放到OnDeck中,作为候选者,但是如果并发比较高,WaitingQueue会被大量线程执行CAS操作,为了降低对尾部元素的竞争,将WaitingQueue拆分成ContentionList和EntryList二个队列,所有请求锁的线程首先尝试自旋获取锁,如果获取不到,被放在ContentionList这个竞争队列中,ContentionList中那些有资格成为候选资源的线程被移动到EntryList中。ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的,Linux内核下采用pthread_mutex_lock内核函数实现的。
第四步,作为Owner的A线程执行过程中,可能调用wait释放锁,这个时候A线程进入WaitSet,等待被唤醒。
📝 JDK6版本及以后
Sun程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,大多数对象的加锁和解锁都是在特定的线程中完成,出现线程竞争锁的情况概率比较低,比例非常高,所以引入了偏向锁和轻量级锁。
64位JVM下的对象结构描述:
对象头的最后两位存储了锁的标志位
没加锁状态,锁标志位01,是否偏向是0,对象头里存储的是对象本身的哈希码。
偏向锁状态,锁标志位01,是否偏向是1,存储的是当前占用对象的线程ID。
轻量级锁状态,锁标志位00,存储指向线程栈中锁记录的指针。
重量级锁状态,锁标志位10,存储的就是重量级锁的指针了。
🔥 对象从无锁到偏向锁转化的过程
第一步,检测MarkWord是否为可偏向状态,是偏向锁是1,锁标识位是01。
第二步,如果是可偏向状态,测试线程ID是不是当前线程ID。如果是,就直接执行同步代码块。
第三步,如果测试线程ID不是当前线程ID,且当前对象的MarkWord标识为可偏向状态,这说明该对象已经被其他线程获取过锁,不能偏向于当前线程,就需要通过CAS操作竞争锁,竞争成功,就把MarkWord的线程ID替换为当前线程ID。
第四步,假设线程B来CAS失败了,需要将当前对象的偏向锁撤销,然后再进行锁竞争,这个时候JVM后台线程会启动偏向锁撤销过程,偏向锁撤销触发通常有二种情况:当在对象头的Epoch字段计数到一定次数时会触发偏向锁撤销或者有多个线程尝试竞争该对象的锁,都失败了,也会触发偏向锁的撤销。撤销偏向锁的过程会挂起所有持有偏向锁的线程(例如线程A),并清除它们的MarkWord中的偏向锁信息(标记位、偏向线程ID、Epoch次数)。然后遍历偏向锁的对象所在的线程的栈,查找锁对象的锁记录,将那些已经被访问过的记录清除,以表明这些线程都不再持有该锁。
第五步,完成偏向锁撤销后,持有偏向锁的线程不会被挂起,而是会继续执行同步代码块。线程A尝试获取该锁,如果获取成功,就可以继续执行同步代码块了,当线程A尝试获取该锁失败时,视情况继续进行自旋或者进行阻塞等待,导致进一步升级为轻量级锁或重量级锁。
安全点是jvm为了保证在垃圾回收的过程中引用关系不会发生变化,设置的安全状态,在这个状态上会暂停所有线程工作。一般有循环的末尾,方法临返回前,调用方法的call指令后,可能抛异常的位置,这些位置都可以算是安全点。
🔥 轻量级锁升级
轻量级锁升级过程是,在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的MarkWord的拷贝,拷贝无锁状态对象头中的MarkWord复制到锁记录中。
- 这么做是因为在申请对象锁时,需要以该值作为CAS的比较条件。
- 同时在升级到重量级锁的时候,能通过这个比较,判定是否在持有锁的过程中,这个锁被其他线程申请过,如果被其他线程申请了,在释放锁的时候要唤醒被挂起的线程。
- 无锁的markword中可能存有hashCode,锁撤销之后必须恢复,这个markword要用于锁撤销后的还原。如果轻量级锁解锁为无锁状态,直接将拷贝的markword CAS修改到锁对象的markword里面就可以了。
拷贝成功后,虚拟机将使用CAS操作把对象中对象头MarkWord替换为指向锁记录的指针,然后把锁记录空间里的owner指针指向加锁的对象,如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象MarkWord的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,虚拟机首先会检查对象MarkWord中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。如果不是说明多个线程竞争锁,进入自旋,若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,MarkWord中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。一般来说,同步代码块内的代码应该很快就执行结束,这时候线程B自旋一段时间是很容易拿到锁的,但是如果不巧,没拿到,自旋其实就是死循环,很耗CPU的,因此就直接转成重量级锁咯,这样就不用了线程一直自旋了。
🔥 自旋锁
自旋锁不是一种锁状态,而是一种策略。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。
引入自旋锁,当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。
自旋的次数必须要有一个限度,如果自旋超过了定义的限度仍然没有获取到锁,就应该被挂起。但是这个限度不能固定,程序锁的状况是不可预估的,所以JDK1.6引入自适应的自旋锁,线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少,甚至省略掉自旋过程,以免浪费处理器资源。
通过–XX:+UseSpinning参数来开启自旋(JDK1.6之前默认关闭自旋)。
通过–XX:PreBlockSpin修改自旋次数,默认值是10次。
🔥 重量级锁
当一个线程在等锁时会不停的自旋(底层就是一个while循环),当自旋的线程达到CPU核数的1/2时,就会升级为重量级锁。
将锁标志为置为10,将MarkWord中指针指向重量级的monitor,阻塞所有没有获取到锁的线程。
Synchronized是通过对象内部的监视器锁(Monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的MutexLock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,状态之间的转换需要比较长的时间,这就是为什么Synchronized效率低的原因,这种依赖于操作系统MutexLock所实现的锁我们称之为“重量级锁”。
重量级锁的加锁-等待-撤销流程:
曾经获得过锁的线程,被唤醒后,优先得到锁。
举个例子,假设有A,B,C三个线程依次进入synchronized区,并且A已经膨胀成重量级锁。如果有一个线程 a 先进入 synchronized , 但是调用了 wait释放锁,这是线程 b 进入了 synchronized,b还在synchronized中执行,c线程又进来了。此时 a 在 wait_set ,b 不在任何队列,c 在 cxq_list ,假如 b 调用 notify唤醒线程,会把 a 插到 c 前面,也就是 b 退出synchronized的时候,会唤醒 a,a退出之后再唤醒 c。
重量级锁撤销之后是无锁状态,撤销锁之后会清除创建的monitor对象并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。GC清除掉monitor对象之后,就会撤销为无锁状态。
🔥 引入偏向锁的好处
- 偏向锁的好处是并发度很低的情况下,同一个线程获取锁不需要内存拷贝的操作,免去了轻量级锁的在线程栈中建LockRecord,拷贝MarkDown的内容。
- 免了重量级锁的底层操作系统用户态到内核态的切换,节省毫无意义的请求锁的时间。
- 另外Hotspot也做了另一项优化,基于锁对象的epoch批量偏移和批量撤销偏移,这样大大降低了偏向锁的CAS和锁撤销带来的损耗。因为基于epoch批量撤销偏向锁和批量加偏向锁能大幅提升吞吐量,但是并发量特别大的时候性能就没有什么特别的提升了。
- 偏向锁减少CAS操作,降低Cache一致性流量,CAS操作会延迟本地调用。
为什么这么说呢?这要从SMP(对称多处理器)架构说起,所有的CPU会共享一条系统总线BUS,靠此总线连接主内存,每个核都有自己的一级缓存,每个核相对于BUS对称分布。
举个例子,我电脑是六核的,假设一个核是Core1,一个核是Core2,这二个核可能会同时把主存中某个位置的值Load到自己的一级缓存中。当Core1在自己的L1Cache中修改这个位置的值时,会通过总线,使Core2中L1Cache对应的值“失效”,而Core2一旦发现自己L1Cache中的值失效,也就是所谓的Cache命中缺失,一旦发现失效就会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信叫做“Cache一致性流量”。如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个CoreCAS成功时必然会引起总线风暴,这就是所谓的本地延迟。
所以偏向锁比较适用于只有一个线程访问同步块场景。
🔥 引入轻量级的好处
对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁通过CAS操作成功,避免了使用互斥量的开销。
对于竞争的线程不会阻塞,提高了程序的响应速度。
如果确实存在锁竞争,始终得不到锁竞争的线程使用自旋会消耗CPU,除了互斥量的本身开销外,还额外发生了CAS操作的开销,轻量级锁反而会比传统的重量级锁更慢。
所以轻量级追求的是响应时间,同步块执行速度非常快的场景。
🍊 ThreadLocal
🎉 定义
ThreadLocal叫做线程变量,这个变量对其他线程而言是隔离的,是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,每个线程可以访问自己内部的副本变量。
🎉 ThreadLocal与Synchronized的区别
1、Synchronized用于线程间的数据共享,ThreadLocal用于线程间的数据隔离。
2、Synchronized是利用锁的机制,让变量或代码块在某一时该只能被一个线程访问,用于在多个线程间通信时能够获得数据共享。ThreadLocal为每一个线程都提供了变量的副本,让每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。
🎉 底层实现
在 Thread 类中嵌入一个 ThreadLocalMap,ThreadLocalMap 就是一个容器,存储的就是这个 Thread 类专享的数据。
📝 ThreadLocalMap底层结构
static class ThreadLocalMap { static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } …… }
ThreadLocal在保存的时候会把自己当做Key存在ThreadLocalMap中,key被设计成WeakReference弱引用了。
ThreadLocalMap的key设计成弱引用,主要是为了避免内存泄漏的情况。如果 threadlocalmap 的 key 是强引用, 那么只要线程存在, threadlocalmap 就存在, 而 threadlocalmap 结构就是 entry 数组. 即对应的 entry 数组就存在, 而 entry 数组元素的 key 是 threadLocal.即便我们在代码中显式赋值 threadlocal 为 null, 告诉 gc 要垃圾回收该对象. 由于上面的强引用存在, threadlocal 即便赋值为 null, 只要线程存在, threadlocal 并不会被回收。
而设置为弱引用, gc 扫描到时, 发现ThreadLocal在没有外部强引用时,发生GC时会被回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。所以在代码最后都需要用remove把值清空。
remove的源码很简单,找到对应的值全部置空,这样在垃圾回收器回收的时候,会自动把他们回收掉。
并且 threadlocal 的 set get remove 都会判断是否 key 为 null, 如果为 null, 那么 value 的也会移除, 之后会被 gc 回收。
结构大致这样:
📝 ThreadLocalMap存储元素的过程
ThreadLocalMap在存储的时候会给每一个ThreadLocal对象一个threadLocalHashCode,在插入过程中,根据ThreadLocal对象的hash值,定位到table中的位置,如果当前位置是空的,就初始化一个Entry对象放在位置上。如果位置不为空,如果这个Entry对象的key正好是即将设置的key,那么就刷新Entry中的value。如果位置不为空,而且key不等于entry,那就找下一个空位置,直到为空为止。在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该位置Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。这种方式在不使用链表的情况下,解决了hash冲突。
📝 ThreadLocal实现线程隔离的原理
ThreadLocal实现线程隔离主要是设置值和获取值的时候,就已经保证了它是线程隔离了。
设置值的代码:
public void set(T value) { Thread t = Thread.currentThread();// 获取当前线程 ThreadLocalMap map = getMap(t);// 获取ThreadLocalMap对象 if (map != null) // 校验对象是否为空 map.set(this, value); // 不为空set else createMap(t, value); // 为空创建一个map对象 }
设置值先是获取当前线程对象,然后从当前线程中获取线程的ThreadLocalMap,判断这个对象是不是空的,如果是空的,就创建一个空的map对象,如果不为空,就重新设值。key就是当前ThreadLocal 的对象,值是添加到这个ThreadLocalMap中的,它是存储在线程内部,然后关联了对应的ThreadLocal。
ThreadLocalMap是当前线程Thread一个叫threadLocals的变量中获取的
ThreadLocalMap getMap(Thread t) { return t.threadLocals; }
public class Thread implements Runnable { …… /* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class. */ ThreadLocal.ThreadLocalMap threadLocals = null; /* * InheritableThreadLocal values pertaining to this thread. This map is * maintained by the InheritableThreadLocal class. */ ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; ……
每个线程Thread都维护了自己的threadLocals变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离。
通过ThreadLocal.get 时就能获取到对应的值。
public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); }
🍊 AQS
AQS的全称是AbstractQueuedSynchronizer,也就是抽象队列同步器,它是在java.util.concurrent.locks包下的,也就是JUC并发包。java提供了synchronized关键字内置锁,还提供了显示锁,而大部分的显示锁的底层都用到了AQS,比如只有一个线程能执行ReentrantLock独占锁,又比如多个线程可以同时执行共享锁Semaphore、CountDownLatch、ReadWriteLock、CyclicBarrier。
同步器自身没有实现任何同步接口,它仅仅是定义了同步状态获取和释放的方法,提供自定义同步组件使用,子类通过继承同步器,实现它的抽象方法来管理同步状态。使用模板方法模式,使用者继承AbstractQueuedSynchronizer,重写指定的方法,重写的方法就是对于共享资源state的获取和释放,在自定义同步组件里调用它的模板方法,这些模板方法会调用使用者重写的方法,这是模板方法模式很经典的一个运用。
同步器依赖内部的一个FIFO双向同步队列来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点并将其加入同步队列,同时会阻塞当前线程。当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
同步器拥有首节点和尾节点,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点, 而后继节点将会在获取同步状态成功时将自己设置为首节点,没有成功获取同步状态的线程会成为节点,加入该队列的尾部。
🎉 独占锁举例
拿ReentrantLock加锁举例,线程调用ReentrantLock的lock()方法进行加锁,这个加锁的过程,用CAS将state值从0变为1。一旦线程加锁成功了之后,就可以设置当前加锁线程是自己。ReentrantLock通过多次执行lock()加锁和unlock()释放锁,对一个锁加多次,从而实现可重入锁,每次线程可重入加锁一次,判断一下当前加锁线程是不是自己,如果是他自己就可以可重入多次加锁,每次加锁,就是把state的值给累加1。
当state=1时代表当前对象锁已经被占用,其他线程来加锁时则会失败,然后再去看加锁线程的变量里面是不是自己之前占用过这把锁,如果不是就说明有其他线程占用了这个锁,失败的线程被放入一个等待队列中,在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,等待已经获得锁的线程,释放锁才能被唤醒。
当它释放锁的时候,将AQS内的state变量的值减1,如果state值为0,就彻底释放锁,会将“加锁线程”变量设置为null。这个时候,会从等待队列的队头唤醒其他线程重新尝试加锁,获得锁成功之后,会把“加锁线程”设置为线程自己,同时线程自己就从等待队列中出队。
🎉 底层实现独占锁
public final void acquice(int arg){ //同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待 if(!tryAcquirce(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE),arg)){ selfInterrupt(); } }
首先调用自定义同步器实现的tryAcquire方法,保证线程安全的获取同步状态。
如果同步状态获取成功直接退出返回。
如果同步状态获取失败,就构造同步节点,通过addWaiter方法把这个节点加入到同步队列的尾部。
最后调用acquireQueued方法,让节点自旋的获取同步状态。
这个就是aqs实现独占锁的底层实现。
🎉 超时获取锁
在Java 5之前,当一个线程获取不到锁而被阻塞在synchronized之外时,对该线程进行中断操作,此时这个线程的中断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。
Java 5中,在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException。
后续的版本又进行了优化,提供了超时获取同步状态过程,可以被当作响应中断,是获取同步状态过程的“增强版”, doAcquireNanos方法在支持响应中断的基础上,增加了超时获取的特性。
针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知, nanosTimeout计算公式为:
nanosTimeout = now-lastTime
,其中now为当前唤醒时间,lastTime为上次唤醒时间。
如果 nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒, 否则,表示已经超时。
如果nanosTimeout小于等于1000纳秒时, 将不会使该线程进行超时等待,而是进入快速的自旋过程。
原因在于,非常短的超时等待,无法做到十分精确,如果这时再进行超时等待,相反会让nanosTimeout的超时从整体上表现得反而不精确。因此,在超时非常短的场景下,同步器会进入无条件的快速自旋。
🎉 共享锁举例
拿CountDownLatch举例,任务分为5个子线程去执行,state也初始化为5。这5个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1,等到所有子线程都执行完后,state=0,会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。
🎉 共享锁实现原理
共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。通过调用同步器的acquireShared方法可以共享式地获取同步状态,只要方法里面的tryAcquireShared方法返回值大于等于0,就可以成功获取到同步状态并退出自旋。对于能够支持多个线程同时访问的并发组件,它和独占式主要区别在于 tryReleaseShared方法必须确保同步状态线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作可能会同时来自多个线程。
🎉 AQS为什么要使用双向链表
首先,双向链表的特点是它有两个指针,一个指针指向前置节点,一个指针指向后继节点。
所以,双向链表可以支持 常量O(1) 时间复杂度的情况下找到前驱结点,基于这样的特点。
双向链表在插入和删除操作的时候,要比单向链表简单、高效。
因此,从双向链表的特性来看,我认为AQS使用双向链表有三个方面的考虑。
- 第一个方面,没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,
这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。
所以线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从head节点开始遍历,性能非常低。 - 第二个方面,在Lock接口里面有一个,lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。
这个时候,被中断的线程的状态会修改成CANCELLED。被标记为CANCELLED状态的线程,是不需要去竞争锁的,但是它仍然存在于双向链表里面。意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。在这种情况下,如果是单向链表,就需要从Head节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。 - 第三个方面,为了避免线程阻塞和唤醒的开销,所以刚加入到链表的线程,首先会通过自旋的方式尝试去竞争锁。但是实际上按照公平锁的设计,只有头节点的下一个节点才有必要去竞争锁,后续的节点竞争锁的意义不大。否则,就会造成羊群效应,也就是大量的线程在阻塞之前尝试去竞争锁带来比较大的性能开销。所以为了避免这个问题,加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是不是头节点,如果不是头节点,就没必要再去触发锁竞争的动作。所以这里会涉及到前置节点的查找,如果是单向链表,那么这个功能的实现会非常复杂。
🍊 线程池
🎉 底层运行原理
线程池就是控制运行的线程数量,处理过程中将任务放到队列,然后在线程创建后启动这些任务,如果线程数量超出了最大数量就排队等候,等其他线程执行完毕再从队列中取出任务执行。
线程池相当于银行网点,常驻核心数相当于今日当值窗口,线程池能够同时执行的最大线程数相当于银行所有的窗口,任务队列相当于银行的候客区,当今日当值窗口满了,多出来的客户去候客区等待,当候客区满了,银行加开窗口,候客区先来的客户去加班窗口,当银行所有的窗口满了,其他客户在候客区等待,同时拒绝其他客户进入银行。当用户少了,加班的窗口等待时间(相当于多余线程存活的时间)(等待时间的单位相当于unit参数)假设超过一个小时还是没有人来,就取消加班的窗口。
🎉 七大核心参数
底层在创建线程池的时候有七个参数:核心线程数,同时执行的最大线程数,多余线程存活时间,单位时间秒,任务队列,默认线程工厂,拒绝策略
maximumPoolsize:同时执行的最大线程数
keepAliveTime:多余线程存活时间,当前线程池数量超过核心线程数时,当前空闲时间达到多余线程存活时间的值的时候,多余空闲线程会被销毁到只剩核心线程数为止
unit:多余线程存活时间的单位
workQueue:任务队列,被提交但尚未被执行的任务
threadFactory:生成线程池的线程工厂
handler:拒绝策略,当队列满了并且工作线程数量大于线程池的最大线程数时,提供拒绝策略。
🎉 如何合理的配置核心线程数?
对于CPU密集型任务,由于CPU密集型任务的性质,导致CPU的使用率很高,如果线程池中的核心线程数量过多,会增加上下文切换的次数,带来额外的开销。因此,考虑到CPU密集型任务因为某些原因而暂停,这个时候有额外的线程能确保CPU这个时刻不会浪费,还可以增加一个CPU上下文切换。一般情况下:线程池的核心线程数量等于CPU核心数+1。例如需要大量的计算,视频渲染啊,仿真啊之类的。这个时候CPU就卯足了劲在运行,这个时候切换线程,反而浪费了切换的时间,效率不高。打个比方,你的大脑是CPU,你本来就在一本心思地写作业,多线程这时候就是要你写会作业,然后立刻敲一会代码,然后在P个图,然后在看个视频,然后再切换回作业。emmmm,过程中你还需要切换(收起来作业,拿出电脑,打开VS…)那你的作业怕是要写到挂科。这个时候你就该一门心思地写作业。
对于I/O密集型任务,由于I/O密集型任务CPU使用率并不是很高,可以让CPU在等待I/O操作的时去处理别的任务,充分利用CPU。一般情况下:线程的核心线程数等于2*CPU核心数。例如你需要陪小姐姐或者小哥哥聊天,还需要下载一个VS,还需要看博客。打个比方,小姐姐给你发消息了,回一下她,然后呢?她给你回消息肯定需要时间,这个时候你就可以搜索VS的网站,先下安装包,然后一看,哎呦,她还没给你回消息,然后看会自己的博客。小姐姐终于回你了,你回一下她,接着看我的博客,这就是类似于IO密集型。你可以在不同的“不烧脑”的工作之间切换,来达到更高的效率。而不是小姐姐不回我的信息,我就干等,啥都不干,就等,这个效率可想而知,也许,小姐姐根本就不会回复你。
对于混合型任务,由于包含2种类型的任务,故混合型任务的线程数与线程时间有关。在某种特定的情况下还可以将任务分为I/O密集型任务和CPU密集型任务,分别让不同的线程池去处理。一般情况下:线程池的核心线程数=(线程等待时间/线程CPU时间+1)*CPU核心数;
并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,我们的项目使用的时redis作为缓存(这类非关系型数据库还是挺好的)。增加服务器是第二步(一般政府项目的首先,因为不用对项目技术做大改动,求一个稳,但前提是资金充足),至于线程池的设置,设置参考 2 。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件(任务时间过长的可以考虑拆分逻辑放入队列等操作)对任务进行拆分和解耦。
🎉 拒绝策略
第一种拒绝策略:AbortPolicy:超出最大线程数,直接抛出RejectedExecutionException异常阻止系统正常运行。可以感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。
第二种拒绝策略:该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,相当于当线程池无能力处理当前任务时,会将这个任务的执行权交予提交任务的线程来执行,也就是谁提交谁负责,从而降低新任务的流量。(谁调用了你,到达最大线程数时,你回去找调用你的人,然后听从调用你的人安排)(超出的我们能办的给你办,不能办的给你回退 )这样的话提交的任务就不会被丢弃而造成业务损失,如果任务比较耗时,那么这段时间内提交任务的线程也会处于忙碌状态而无法继续提交任务,这样也就减缓了任务的提交速度,这相当于一个负反馈,也有利于线程池中的线程来消化任务。这种策略算是最完善的相对于其他三个。
第三拒绝策略:DiscardOldestPolicy:抛弃队列中等待最久的任务,也就是它丢弃的是队列中的头节点,然后把当前任务加入队列中尝试再次提交当前任务。
第四种拒绝策略:DiscardPolicy:直接丢弃任务,不予任何处理也不抛异常,当任务提交时直接将刚提交的任务丢弃,而且不会给与任何提示通知。
🎉 实际创建线程池
java.util.concurrent 包里提供的 Executors 也可以用来创建线程池
- newSingleThreadExecutos 单线程线程池,也就是线程池只有一个任务,这个我偶尔用一用
- newFixedThreadPool(int nThreads) 固定大小线程的线程池
- newCachedThreadPool() 无界线程池,这个就是无论多少任务,都创建线程来运行,所以队列相当于没用。
在实际使用的时候,选择线程池的时候尽量不用JDK提供的三种常见的创建方式
第一是 Executors 提供的线程池使用场景很有限,一般场景很难用到
第二他们也都是通过 ThreadPoolExecutor 创建的线程池,我直接用 ThreadPoolExecutor 创建线程池,可以理解原理,灵活度更高。
第三因为它的底层队列是Linked这个接近于无界,非常大,这样会堆积大量的请求,从而导致OOM,阿里巴巴开发手册推荐我们使用ThreadPoolExecutor去创建线程池。