多线程知识总结

一、ThreadLocal

    ThreadLocal是一个线程级的局部变量,“本地线程”只是俗称但并不准确。

    假设是拿Map去做线程的局部变量,一般就两种思路:以Thread为key的共享区域,使用上不会有什么问题,但因为是共享区域访问,我们要做并发控制比如synchronized这样的悲观策略来保证线程安全,可同步会限制吞吐。第二种就是线程封闭,ThreadLocal正是使用了这种方式,线程本地维护一个区域ThreadLocalMap,以ThreadLocal实例为key,线程间数据隔离,自然就不存在线程安全的问题了。即ThreadLocal作为句柄、一个入口,来连接ThreadLocalMap和Thread。

    内存泄漏问题:ThreadLocalMap的key使用了弱引用,指向某个ThreadLocal实例。当把ThreadLocal实例置为null后,那么它将会被GC回收。但ThreadLocalMap中的value不会被回收,因为存在一条current thread的强引用。因为ThreadLocalMap出现了大量key为null的Entry(key被回收了),且没办法访问Entry的value,只有当前thread结束、强引用断开,当前Thread、ThreadLocalMap,ThreadLocalMap中的value才会被GC回收。

    为什么key要使用弱引用?如果key使用强引用,ThreadLocal不会被回收,因为存在一条ThreadLocalMap的key对它的强引用;如果使用弱引用,能保证ThreadLocal能被GC掉。但不管是强引用还是弱引用,都不影响存在无法访问Entry的事实,都需要事后去手动删除无用的Entry,否则都存在内存泄漏的隐患(Thread不死,无用的Entry就一直在增长)。

    这也就是为什么会有内存泄漏的问题了:存在一条强引用链 Current Thread -> ThreaLocalMap -> Entry -> value。比如我们使用业务线程池中的线程作为ThreadLocalMap的引用的时候,或者使用单例、static的ThreadLocal时(因为延长了ThreadLocal的生命周期),又或者使用ThreadLocal作为每次调用上下文而又不清理的时候,经管ThreadLocal在get、set、remove方法都会去清除线程ThreadLocalMap里所有key为null的value,但这需要我们主动的显示调用才起作用。

    带来的思考:

  • 避免使用同步的方法之一就是不共享数据,比如线程封闭,通过比如转换为单线程,即便对象本身不是线程安全的也不会有什么问题;

  • 给每一个访问共享变量的线程一个独立的副本,可以解决变量并发访问的冲突问题,一种空间换时间的做法;

 

二、synchronized对象锁

    为了保证synchronized的可重入性,JDK为每一个对象的内置锁都关联了一个计数器和所有者线程,为0就是未被持有,为1就是有一个线程,为2就是这个线程再次请求了这个锁,退出则递减。这背后的原理,是synchronized关键字经过编译后,在同步代码块的前后分别形成MonitorEnter和MonitorExit两个字节码指令。执行MonitorEnter指令时获取对象的内置锁,如果没有被锁定,则锁的计数器加1;执行MonitorExit指令时锁的计数器减1,计数器为0时锁就释放了。如果获取锁失败则线程阻塞等待,直到被唤醒。

    而关于synchronized锁的状态:无锁,偏向锁,轻量级锁,重量级锁,这存在于Java对象头MarkWord,根据锁标志位复用空间。无锁状态为空,偏向锁时的ThreadID,轻量锁时锁记录的指针,重量锁时指向系统的互斥量Mutex(0|1)。

偏向锁,适用于单线程访问同步块的场景,同步但无竞争的情况,但只要存在不同线程申请锁时即升级为轻量锁。无锁模式下和偏向锁模式下的性能消耗:CAS替换和退出偏向锁时的撤销。

轻量锁是适用于线程交替执行同步块的场景,有竞争但无阻塞的情况,追求响应时间,但只要存在有锁竞争时即升级为重量锁。

 

三、 Lock

1.  ReentrantLock

    ReentrantLock的内部实现,简单来说,就是在获取锁时先判断state是不是0,如果是就先cas一把获取锁对象,如果不是0,则链到CLH等待队列(双端队列)队尾,标记为独占。如果放入失败,则自旋+CAS,然后进入wait状态,等待unpark()。唤醒后尝试能否cas锁资源成功,成功则指向head队头,然后返回中断标识。

    Reentrant的可重入性,其实是依靠AQS的内部机制的。

AQS(AbstractQueuedSychronizer)也叫“队列同步器”,是整个Java并发包的核心,多种锁的抽象父类。其内部维护了一个volatile的int类型成员变量state和CLH,并且具有两个Node节点的引用:head和tail:

    

  • 如果申请锁资源时,当前线程与独占的Owner线程一致,则state值CAS加1,退出则CAS减1,获取多少次就要释放多么次,直到state回到零态,然后unpark。
  • CLH具体实现上,是由内部类Node构成的FIFO双向同步队列。当竞争锁资源失败后,AQS会将当前线程的引用以及等待状态构造成一个Node并加入到CLH中并阻塞当前线程;每次入CLH队列都要通过CAS操作,使tail指向新节点、新节点prev字段指向原来的尾节点;当首节点锁资源释放后,将后继节点的线程唤醒,而后继节点的线程在尝试获取锁资源成功后将自身设置为首节点。

    

对于公平锁和非公平锁:它们的差异就是在获取锁资源时,非公平是抢占式的,先cas一把不排队直接插队,失败才走acquire(1);然后第二个差别是在acquire时,公平锁会先判断CLH队列中是否空闲,而非公平是没有的。

对于共享锁和独占锁:共享锁,在竞争到锁资源后成为Head头时,如果CLH队列不为空则还会唤醒下一个线程;在释放锁时,独占锁是state=0才去唤醒其它线程,而共享锁则不管state是否为0都会去尝试唤醒。

 

2.  StampedLock

    StampedLock是Java8新增的读写锁,除读写分离外,它的亮点在于乐观读模式,就是在读多写少的情况下,先乐观的读(不阻塞写),而后通过冲突检测来决定是做后续操作,还是重新做悲观的读锁定(即ReadWriteLock的读读不互斥、读写互斥)。
    虽然没有像其它锁一样定义了内部类来实现AQS,但StampedLock的内部实现还是基于CLH(维护一个等待队列,所有没有成功申请锁的线程都放在这个FIFO队列中,然后通过一个标记位locked判断当前线程是否已释放锁)的,通过状态位来表示锁的状态和类型。而且除乐观读之外,其余锁操作都是典型的CAS操作,先自旋尝试、加入CLH等待队列、再次自旋尝试、直至最终Unsafe.park()挂起线程,成功获取锁资源则CAS操作更新标志位。

StampedLock与ReadWriteLock的区别:

  • StampedLock可以做乐观读,而ReadWriteLock使用的都是悲观策略;
  • StampedLock关注的是读写比例,而ReadWriteLock关注的是读写;
  • StampedLock读写锁可以互相转换,而ReadWriteLock只有锁降级;
  • ReentrantReadWriteLock是可重入的,而StampedLock三种锁模式都是不可重入的,它并没有直接实现AQS;
  • StampedLock对多核CPU做了优化(通过Runtime#availableProcessors()获取CPU核数作为自旋次数);
  • 在性能上,正常情况下,StampedLock比ReadWritLock快4倍的读,快1倍的写

 

3.  Volatile

    volatile的作用其实就两个:保证线程可见性,禁止指令重排序。

    通常情况下,为了保证cache一致性,工作内存发生变化之后需要回写到主内存,不管你通过什么方式。而volatile修饰的变量则要求工作内存与主内存保持同步,发生更新立即回写、读的时候读主内存。其实是,JVM在volatile变量前后会插入一个内存屏障指令Store-Load。一个CPU内存访问的一个同步点,屏障之前的写操作都要写入内存、屏障之后的读操作都可以读到屏障之前的写操作结果。这其实是必须要保证的,CPU硬件级别也是有缓存的,就是寄存器。当一个变量被修改时是在其寄存器上操作,如果没有及时回写到物理内存上,线程可见性也难以保证。

    CPU有它自己的指令排序,随机写的性能肯定比不上批处理方式的刷新,而且还可以合并对同一个内存地址的多次写,以减少内存总线的占用。所以,为了保证内存可见性,对于编译器,JVM会禁止volatile变量的编译器重排序;对于处理器,JVM会要求Java编译器在生成指令序列时,插入内存屏障指令,通过内存屏障指定来禁止特定类型的处理器重排序。可想而知这降低了效率,无法被编译器优化。

volatile与sychronized的区别:

sychronized也保证了线程可见性,也在锁即将释放之前将工作内存回写到主内存,但它范围更大,它锁定了一块内存区域做同步,而volatile只是锁定一个变量,所以后者无法替代前者。
实际上也没法替代,volatile只保证可见性不保证原子性,而synchronized是保证的。

 

4.  CAS

    CAS是“Compare And Set”的简称,一种系统原语,JDK通过Unsafe对象来实现CAS,利用native方法的原子性来保证线程安全。
    简单来说,就是一个CAS操作包含三个操作数——内存值(V)、预期值(A)和新值(B)。当且仅当预期值A和内存值V相同时,才会将V改为B并返回true,否则什么都不做并返回false。而底层实现,在比如常见的Linux的X86上,是通过调用Atomic:comxchg()指令(这个方法的实现在HotSpot下的os_cpu包中,该方法的实现和操作系统相关)来直接操作内存的,而这个指令在多处理器情况下会通过使用lock-xadd来加锁。

    synchronized在JDK1.6之后,通过偏向锁 -> 轻量级锁 -> 重量级锁这样的锁优化,大幅的提高了获取锁和释放锁的效率。但在Java8之后,CAS得到了增强,AtomicI包的性能比synchronized更好了。具体原因是,CAS失败自旋用到了JDK1.8中Unsafe新增的getAndAddXXX方法,如果系统底层支持fetch-and-add,则使用fetch-and-add这样的原子指令(get和cas都是native,自然更快);如果不支持,则使用原来的compare-and-swap原子指令。

    CAS使用中要注意的问题主要是ABA和CPU资源浪费:

  • ABA问题:比如内存值变化了之后恢复成原值,但不代表内存值就没有发生变化。一个解决的思路是:通过添加修改计数器或是版本号,来标记是否发生过变更。实际上,JAVA中提供的Atomic原子类型变量就是这么做的,其内部实现是在对象中额外增加了一个标记位来标识对象是否有过变更。
  • CPU问题:如果自旋时间长,则可能浪费CPU这样的宝贵资源,即使没有任何争用也会做一些无用功。所以要明确适用场景,比如简单的非阻塞操作可以考虑使用CAS操作。而且建议与volatile变量“打配合”,来保证每次拿到的变量是主内存中的最新值,否则旧的预期值对某个线程来说,永远是一个不会变的值,只要某次CAS操作失败,则永远不会成功。

Java8之后,这样的并发处理成为主流:通过双层for循环+CAS自旋这样的无锁算法来处理并发,即外层死循环、里层自旋尝试,成功的话直接return或者goto关键字跳出外层循环并结束CAS操作,而不是通过锁竞争、阻塞的方式。

 

四、 线程池

    拿ThreadPoolExecutor来说,工作队列workQueue和工作线程池works是分开的。它的工作机制是这样的:

    

    关于排队策略,针对workQueue,通常有三种排队策略:直接提交,无界队列,有界队列。

  • 直接提交:使用同步阻塞队列,将任务直接提交给线程,如果当前没有空闲线程则创建一个新的。它是无界的;
  • 无界队列:使用链表阻塞队列,如果没有空闲线程则将任务提交给队列。它也是无界的,而且活跃线程数只会是corePoolSize值;
  • 有界队列:使用列表阻塞队列,这时maxPoolSize有效,队列未满则放入队列阻塞,队列已满则跑拒绝策略;

    具体使用哪种排队策略,还是看业务场景和任务量,比如说任务量不大、瞬发情况多,那可以用无界队列;如果任务量大,则要考虑用有界队列去调整。实际情况较常用的是有界队列,以避免耗尽资源导致OOM(同步队列会创建大量线程阻塞,无界队列会堆积大量任务),常见的使用策略是大型队列+小型池(吞吐高、响应慢)或者小型队列+大型池(吞吐低、响应快)。

使用Executors获取线程池的弊端:
   - newFixedThreadPool()和newSingleThreadPool()方法:允许工作队列⻓度为Integer.MAX_VALUE,这可能会堆积大量请求,从而导致OOM;
   - newCachedThreadPoolnew和newScheduledThreadPool()方法:允许创建的线程数为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM;

    关于线程池的饱和(拒绝)策略,主要有四种:

  • 抛异常、丢任务,ThreadPoolExecutor的默认策略;
  • 不抛异常、丢任务;
  • 在pool没有关闭的前提下,丢弃列表头任务,尝试执行新任务;
  • 尝试用调用者线程即主线程去执行任务(由于池中已无任何资源,这其实是风险最高的);

    关于线程池大小设置,其实常见的参数就那么几个:corePoolSize核心线程数、maxPoolSize最大线程数、queueCapacity队列容量、keepAliveSeconds空闲时间,前两个比较重要。总结来说,主要是这三种:

  • CPU密集型:CPU使用率高,为减少线程上下文切换开销,将池设置大小为CPU核数+1;
  • I/O密集型:因为IO操作不占CPU,为了充分利用CPU,将池设置大小为CPU核数*2+1;
  • 混合型:预估I/O密集型和CPU密集型的执行时间,执行时间差不多就拆分开,差很多就选其一;

    CPU密集型会考虑上下文切换的开销,儿I/O密集型会考虑I/O等待耗时。具体到实践中,主要也是看业务场景、什么样的任务。一般规律是:线程等待时间占比越高,需要越多线程数;CPU时间占比越高,需要越少线程数。当然,实际上还要考虑容量占比,不要超过阈值,保持应用的健康状态。

赞(52) 打赏
未经允许不得转载:优客志 » JAVA开发

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏