一问多线程就崩溃?别怕,我来帮你!

欢迎访问我的blog http://www.codinglemon.cn/

立个flag,8月20日前整理出所有面试常见问题,包括有:
Java基础、JVM、多线程、Spring、Redis、MySQL、Zookeeper、Dubbo、RokectMQ、分布式锁、算法。

5. 多线程篇

5.1 Synchronized

在学习Synchronized之前,我们先了解一下Java对象的构成。在JVM中,对象在内存中分为三块区域:

  1. 对象头

    1. Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
    2. Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  2. 实例数据
    这部分主要是存放类的数据信息,父类的信息。

  3. 对其填充
    由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。

tips:一个空对象占8个字节,不足8个字节对其填充会帮我们自动补齐。

5.1.1 Synchronized如何修饰方法?

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法

当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的却是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

总结: Synchronized修饰方法的时候,不看monitorenter和monitorexit指令,而是看ACC_SYNCHRONIZED标识。无论方法是否成功执行最后都会自动释放monitor。

5.1.2 synchronized如何修饰代码块?

同步代码块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

总结:

  1. synchronized修饰代码块使用monitorenter和monitorexit。一个monitorenter必定对应一个monitorexit,monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。

  2. 执行monitorenter,线程尝试获取对象锁对应的monitor持有权,当objectref的monitor进入计数器为0就可以获得monitor,或者该线程已经拥有了objectref的monitor的所有权,那么将重入monitor,计数器加1;否则将阻塞等待。

  3. 为了保证monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,它的目的就是用来执行 monitorexit 指令。

5.1.3 描述一下synchronized的锁升级过程(锁膨胀)?(问synchronized必问)

synchronized锁升级方向如下,并且锁升级过程是不可逆的

image.png

  1. 无锁:这个时候没有线程竞争,也没有加锁。

  2. 偏向锁(只有一个线程进入临界区):大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁。当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程再进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置为1(表示指向当前进程):如果没有,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前进程。

总结: 线程访问对象头,比较对象头存储的线程ID,如果相同说明是同一个线程,再看对象头的Mark Word的偏向锁标识是否为1,如果是则加1,直接进入,如果不是则用CAS竞争锁(此时竞争成功会将偏向锁标识设置为1);如果线程ID不同,则锁升级为轻量级锁。
在这里插入图片描述

  1. 轻量级锁(多个线程交替进入临界区):轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。

    1. 还是跟Mark Work 相关,如果这个对象是无锁的,jvm就会在当前线程的栈帧中建立一个叫 锁记录(Lock Record) 的空间,用来存储锁对象的Mark Word 拷贝,然后把Lock Record中的owner指向当前对象
    2. JVM接下来会利用CAS尝试把对象原本的Mark Word 更新会Lock Record的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。
    3. 如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。
      在这里插入图片描述

总结:轻量级锁认为多个线程是交替进入临界区的,此时其实在时间片内竞争不存在,线程会先看能否修改Lock Record的指针指向自己,如果成功说明加锁成功;如果失败判断一下Lock Record的指针是否已经指向自己,如果是则重入锁,如果不是则继续升级锁。

  1. 自旋锁(包含在轻量级锁的操作过程中):线程不断的请求获得锁,在等待设定次数后,再将锁升级。
    在这里插入图片描述

  2. 重量级锁:当线程自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。重量级锁会在用户态和内核态之间切换,代价很大。

5.1.4 synchronized保证了那些特性?(常问)

  1. 有序性:as-if-serial和Happens-Before原则,即一个操作在另一个操作前面,不会重排序。使用volatile关键字保证多线程条件下不会进行指令重排序。

  2. 可见性:保证共享变量的修改能够及时可见,synchronized 关键字,开始时会从内存中读取,结束时会将变化刷新到内存中,所以是可见的。volatile 关键值,通过添加lock指令,也是可见的。

  3. 原子性:保证单一线程持有

  4. 可重入性:synchronized和ReentrantLock都是可重入锁。每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。

5.2 CAS(Compare And Swap)

前面提到了CAS,这里对CAS进行一个说明。

CAS是compare and swap的简称,从字面上理解就是比较并更新,简单来说:从某一内存上取值V,和预期值A进行比较,如果内存值V和预期值A的结果相等,那么我们就把新值B更新到内存,如果不相等,那么就重复上述操作直到成功为止。

上面我们了解了CAS是什么了,那么它能解决什么问题呢?它可以解决多线程并发安全的问题,以前我们对一些多线程操作的代码都是使用synchronize关键字,来保证线程安全的问题;现在我们将CAS放入到多线程环境里我们看一下它是怎么解决的,我们假设有A、B两个线程同时执行一个int值value自增的代码,并且同时获取了当前的value,我们还要假设线程B比A快了那么0.00000001s,所以B先执行,线程B执行了CAS操作之后,发现当前值和预期值相符,就执行了自增操作,此时这个value = value + 1;然后A开始执行,A也执行了CAS操作,但是此时value的值和它当时取到的值已经不一样了,所以此次操作失败,重新取值然后比较成功,然后将value值更新,这样两个线程进入,value值自增了两次,符合我们的预期。

5.2.1 CAS如何解决ABA问题?

因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

解决方法:ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。或者增加时间戳(原理与增加版本号类似)

5.2.2 自旋CAS如何解决循环时间长开销大问题?

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

tips:一般自旋CAS会有次数限制,超过既定次数会升级锁。

5.2.3 CAS只能保证一个共享变量的原子操作吗?

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

5.3 ThreadLocal

如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。也就是说这是ThreadLocal的值在每个线程中独享自己的副本。

5.3.1 ThreadLocal的内存泄露问题

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法(ThreadLocalMap使用的是简单的线性探测法,如果发生了元素冲突,那么就使用下一个槽位存放)。

总结: 也就是说ThreadLocalMap 中的key因为是弱引用,在ThreadLocal没有被外部强引用的情况下,垃圾回收时,key会被回收掉,变为null,但value不会被回收,造成内存泄漏。 ThreadLocalMap调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。

问:什么是强引用?什么是弱引用?

答:
1. 强引用:一直活着:类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。
2. 回收就会死亡:被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在JDK 1.2之后,提供了WeakReference类来实现弱引用。

问:为什么ThreadLocalMap使用弱引用?

答: 两种情况:
1. key 使用强引用:引用ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
2. key 使用弱引用:引用ThreadLocal的对象被回收了,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用set、get、remove的时候会被清除。

比较两种情况,我们可以发现:由于ThreadLocalMap的生命周期跟Thread一样长,如果都没有手动删除对应key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal被清理后key为null,对应的value在下一次ThreadLocalMap调用set、get、remove的时候可能会被清除

因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。

问:怎样避免ThreadLocal的内存泄露问题?

答:每次使用完ThreadLocal,建议调用它的remove()方法,清除数据。另外需要强调的是并不是所有使用ThreadLocal的地方,都要在最后remove(),因为他们的生命周期可能是需要和项目的生存周期一样长的,所以要进行恰当的选择,以免出现业务逻辑错误!

5.3.2 什么情况下使用ThreadLocal?

  • 当需要存储线程私有变量的时候。
  • 当需要实现线程安全的变量时。
  • 当需要减少线程资源竞争的时候。

注:Spring采用Threadlocal的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接,同时,采用这种方式可以使业务层使用事务时不需要感知并管理connection对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。

5.4 volatile 关键字

每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写回了,他其他已经读取的线程的变量副本就会失效了,需要对数据进行操作,又要再次去主内存中读取了。

volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

image.png

之前我们说过当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。

如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?

为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,重点了解MESI(缓存一致性协议)

5.4.1 MESI

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

如何发现数据是否失效呢?

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

总线风暴:

由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值

所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。

所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。

5.4.2 Java内存模型JMM(Java Memory Model)

本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

5.4.3 volatile禁止指令重排序

说这个之前,还要了解几个问题:

  1. 指令重排序:为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

  2. as-if-serial:不管怎么重排序,单线程下的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

  3. 内存屏障:java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

  4. happens-before: 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。

说人话:也就是说,必须等到该volatile域写操作完成,才能执行对该volatile域的读操作。

5.4.4 volatile可以保证原子性吗?

Volatile是没办法保证原子性的,一定要保证原子性,可以使用其他方法。

保证原子性:就是一次操作,要么完全成功,要么完全失败。

假设现在有N个线程对同一个变量进行累加也是没办法保证结果是对的,因为读写这个过程并不是原子性的。

要解决也简单,要么用原子类,比如AtomicInteger,要么加锁(记得关注Atomic的底层)。

5.4.5 volatile与synchronized的区别

  1. volatile只能修饰实例变量类变量,而synchronized可以修饰方法,以及代码块

  2. volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制,也就是都保证。

  3. volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。

  4. volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

5.4.6 总结

  1. volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如booleanflag;或者作为触发器,实现轻量级同步。

  2. volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。

  3. volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。

  4. volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主 存中读取。

  5. volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。

  6. volatile可以使得long和double的赋值是原子的。

  7. volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。

5.4.7 volatile+synchronized实现单例模式的经典代码

public class Singleton {
    private static volatile Singleton INSTANCE;
 
    public static Singleton getInstance(){
        if(INSTANCE == null){
            synchronized (Singleton.class){
		//双重检查
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

5.5 线程池

5.5.1 Java提供的线程池有哪几种?作用分别是什么?

  1. newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。newFixedThreadPool固定线程池,使用完毕必须手动关闭线程池,否则会一直在内存中存在。

  2. newCacheThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

  3. newSIngleTheadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

  4. newScheduledThewadPool:创建一个定长线程池,支持定时及周期性任务执行。

  5. newWorkStealingPool(1.8新增):创建一个抢占式执行的线程池(任务执行顺序不确定),注意此方法只有在 JDK 1.8+ 版本中才能使用。

  6. ThreadPoolExecutor:最原始的创建线程池的方式。

5.5.2 线程池有那些参数?

  1. 参数 1:corePoolSize
    核心线程数,线程池中始终存活的线程数。

  2. 参数 2:maximumPoolSize
    最大线程数,线程池中允许的最大线程数,当线程池的任务队列满了之后可以创建的最大线程数。(还有一个largestPoolSize: 是一个动态变量,是记录Poll曾经达到的最高值,也就是 largestPoolSize<= maximumPoolSize。)

3.参数 3:keepAliveTime
最大线程数可以存活的时间,当线程中没有任务执行时,最大线程就会销毁一部分,最终保持核心线程数量的线程。

4.参数 4:unit:
单位是和参数 3 存活时间配合使用的,合在一起用于设定线程的存活时间 ,参数 keepAliveTime 的时间单位有以下 7 种可选:

1. TimeUnit.DAYS:天
2. TimeUnit.HOURS:小时
3. TimeUnit.MINUTES:分
4. TimeUnit.SECONDS:秒
5. TimeUnit.MILLISECONDS:毫秒
6. TimeUnit.MICROSECONDS:微妙
7. TimeUnit.NANOSECONDS:纳秒

5.参数 5:workQueue
一个阻塞队列,用来存储线程池等待执行的任务,均为线程安全,它包含以下 7 种类型:

1. ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
2. LinkedBlockingQueue:一个由链表结构组成的无界阻塞队列。
3. SynchronousQueue:一个不存储元素的阻塞队列,即直接提交给线程不保持它们。
4. PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
5. DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素。
6. LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。与SynchronousQueue类似,还含有非阻塞方法。
7. LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

较常用的是 LinkedBlockingQueueSynchronous,线程池的排队策略与 BlockingQueue 有关。

6.参数 6:threadFactory
线程工厂,主要用来创建线程,默认为正常优先级、非守护线程。

7.参数 7:handler(常问)
拒绝策略,拒绝处理任务时的策略,系统提供了 4 种可选:

1. AbortPolicy:拒绝并抛出异常。
2. CallerRunsPolicy:使用当前调用的线程来执行此任务(重试)。
3. DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。
4. DiscardPolicy:忽略并抛弃当前任务。

默认策略为 AbortPolicy

ThreadPoolExecutor 关键节点的执行流程如下:

  1. 当线程数小于核心线程数时,创建线程。
  2. 当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。
  3. 当线程数大于等于核心线程数,且任务队列已满:若线程数小于最大线程数,创建线程;若线程数等于最大线程数,抛出异常,拒绝任务。

5.5.3 该如何选择线程池?

我们来看下阿里巴巴《Java开发手册》给我们的答案:

【强制要求】 线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:

  1. FixedThreadPool 和 SingleThreadPool:允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
  2. CachedThreadPool:允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

所以综上情况所述,我们推荐使用 ThreadPoolExecutor 的方式进行线程池的创建,因为这种创建方式更可控,并且更加明确了线程池的运行规则,可以规避一些未知的风险。

说人话:我们在使用线程池时不要使用Java已有的线程池模型(因为有这样或那样的缺陷),而是通过自定义线程池参数,创建自定义的线程池。

实际使用线程池的地方:商品详情页、批处理等等。

线程池的执行过程:

核心线程->队列->最大线程->拒绝策略

5.6 CountDownLatch,CyclicBarrier,Semaphore

5.6.1 CountDownLatch

它是一个同步辅助器,允许一个或多个线程一直等待,直到一组在其他线程执行的操作全部完成。
当一个线程调用await方法时,就会阻塞当前线程。每当有线程调用一次 countDown 方法时,计数就会减 1。当 count 的值等于 0 的时候,被阻塞的线程才会继续运行。

说人话:某个线程工作时,某些情况下需要前面所有线程都运行完了再执行,那么就使用CountDownLatch来阻塞当前线程,完成一次就调用一次countDown()方法,让计数器减一,当count 值为0表示所有线程工作完成,再执行该线程。

5.6.2 CyclicBarrier

一组线程会互相等待,直到所有线程都到达一个同步点。这个就非常有意思了,就像一群人被困到了一个栅栏前面,只有等最后一个人到达之后,他们才可以合力把栅栏(屏障)突破。

说人话:让所有线程阻塞在相同的同步点,大家都到达了再一起执行。

5.6.3 Semaphore

Semaphore 信号量,用来控制同一时间,资源可被访问的线程数量,一般可用于流量的控制。

5.6.4 总结

  1. CountDownLatch 是一个线程等待其他线程, CyclicBarrier 是多个线程互相等待。

  2. CountDownLatch 的计数是减 1 直到 0,CyclicBarrier 是加 1,直到指定值。

  3. CountDownLatch 是一次性的, CyclicBarrier 可以循环利用。

  4. CyclicBarrier 可以在最后一个线程达到屏障之前,选择先执行一个操作。

  5. Semaphore ,需要拿到许可才能执行,并可以选择公平和非公平模式。

5.7 AQS

谈到并发,我们不得不说AQS(AbstractQueuedSynchronizer),所谓的AQS即是抽象的队列式的同步器,内部定义了很多锁相关的方法,我们熟知的ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore等都是基于AQS来实现的。

5.7.1 AQS原理

AQS中 维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。

这里volatile能够保证多线程下的可见性,当state=1则代表当前对象锁已经被占有,其他线程来加锁时则会失败,加锁失败的线程会被放入一个FIFO的等待队列中,比列会被UNSAFE.park()操作挂起,等待其他获取锁的线程释放锁才能够被唤醒。

另外state的操作都是通过CAS来保证其并发修改的安全性。

具体原理我们可以用一张图来简单概括:

在这里插入图片描述

AQS 中提供了很多关于锁的实现方法:

  1. getState():获取锁的标志state值
  2. setState():设置锁的标志state值
  3. tryAcquire(int):独占方式获取锁。尝试获取资源,成功则返回true,失败则返回false。
  4. tryRelease(int):独占方式释放锁。尝试释放资源,成功则返回true,失败则返回false。

tips: AQS其他内容可能会单独拿一章出来讲…内容太多了T T!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值