基础知识
并发编程的三个重要特性
- 原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronized 可以保证代码片段的原子性。
- 可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
- 有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。
(Java中)线程有哪些基本状态?
- Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态(图源《Java 并发编程艺术》4.1.4 节)。
- Runnable:包括了操作系统中的就绪和运行
- Wait:无限期等待,需要被唤醒
- Object.wait()
- Thread.join
- LockSupport.park()
- Time_wait:限期等待,会自动唤醒
- Thread.sleep()
- Object.wait(time)
- Thread.join(time)
- LockSupport.parkNanos()
- LockSupport.parkUntil()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MV7MIfAa-1598618731936)(en-resource://database/7403:1)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h1tNcsKK-1598618731939)(en-resource://database/7405:1)]
进程线程的区别
- 一个进程中可以有多个线程,多个线程共享进程的堆和方法区(JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈。
- 线程是进程 划分成的更小的运行单位。
- 线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。
- 线程执行开销小,但不利于资源的管理和保护;而进程正相反
sleep和wait的异同
两者都可以暂停线程的执行
- 类的不同:
sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。 - 是否释放锁:
sleep() 不释放锁;wait() 释放锁。 - 用途不同:
Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。 - 用法不同:
wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。
线程类的构造方法、静态块是被哪个线程调用的
线程类的构造方法、静态块是被 new这个线程类所在的线程所调用的,而 run 方法里面的代码才是被线程自身所调用的。
假设 Thread2 中 new 了Thread1,main 函数中 new 了 Thread2,那么:
(1)Thread2 的构造方法、静态块是 main 线程调用的,Thread2 的 run()方法是Thread2 自己调用的
(2)Thread1 的构造方法、静态块是 Thread2 调用的,Thread1 的 run()方法是Thread1 自己调用的
并发理论
如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?
不会,在下一个垃圾回调周期中,这个对象将是被可回收的。
为什么代码会重排序?
在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,它需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果
- 存在数据依赖关系的不允许重排序
需要注意的是:重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。
并发关键字
Synchronized作用
-
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
-
早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。
-
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
如何使用Synchronized
-
修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
-
修饰静态方法: 也就是给当前类加锁,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,二者不冲突。
-
修饰代码块: 指定加锁对象(锁的是括号里的对象),对给定对象加锁,进入同步代码库前要获得给定对象的锁。
synchronized的底层实现
synchronized 关键字底层原理属于 JVM 层面
-
synchronized同步语句块
- 使用的是monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置
- 当执行 monitorenter 指令时,线程试图获取锁也就是获取monitor(监视器锁)的持有权。monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。
- 当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
-
synchronized 修饰方法
- 并没有 monitorenter 指令和 monitorexit 指令,取得代之的是ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
-
synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
JDK1.6 synchronized的底层优化
- JDK1.6对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
- 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
- 注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
- 偏向锁
- 轻量级锁在无竞争的情况下使用CAS操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。
- 偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步。
- 但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失。
- 需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
- 轻量级锁
- 倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。
- 轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了CAS操作。
- 轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。
- 如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!
- 自旋锁和自适应自旋
- 轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
- 互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。
- 一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。
- 自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过–XX:+UseSpinning参数来开启。**JDK1.6及1.6之后,就改为默认开启的了。**需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改–XX:PreBlockSpin来更改。
- 另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。
- 锁消除
- 锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。
- 锁粗化
- 原则上,推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。
- 大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁的操作在循环体里,即使没有线程竞争频繁的互斥同步也会带来很多不必要的性能消耗。
- 如果JVM探测到这种情况,会把加锁同步范围扩展(粗化)
Synchronized 和 ReenTrantLock 的对比
-
两者都是可重入锁
-
synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成) -
synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
-
synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
-
相比synchronized,ReenTrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
- ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
- ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。 所谓的公平锁就是先等待的线程先获得锁。 ReenTrantLock默认情况是非公平的,可以通过 ReenTrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
- synchronized关键字与wait()和notify/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。
volatile关键字的语义
- 保证可见性
- 禁止指令重排序优化
说说 synchronized 关键字和 volatile 关键字的区别
对比 | synchronized | volatile |
---|---|---|
修饰场景 | 类,方法,代码块,变量 | 变量 |
性能 | 较低 | 较高 |
是否阻塞 | 可能造成阻塞 | 不会发生阻塞 |
原子性 | 原子性和可见性 | 只保证可见性 |
volatile 能使得一个非原子操作变成原子操作吗?
- 关键字volatile的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步。
- 虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。
- 对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。
- 在操作的时候,可以分成两步,每次对32位操作。
- 如果使用volatile修饰long和double,那么其读写都是原子操作
- 对于64位的引用地址的读写,都是原子操作
- 在实现JVM时,可以自由选择是否把读写long和double作为原子操作
- 推荐JVM实现为原子操作
并发容器
CopyOnWriteArrayList
-
ReentrantReadWriteLock:读读共享、写写互斥、读写互斥、写读互斥
-
CopyOnWriteArrayList思想更进一步, 读取是完全不用加锁的,写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升
-
简单原理
- 所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。
- 当List需要被修改的时候并不修改原有内容,而是对原有数据进行一次复制将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
-
源码简单分析:
- 读取操作:没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1Q3oVjrd-1598618731942)(en-resource://database/7425:1)] - 写入操作:写入操作 add() 方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mn8SS4Hq-1598618731945)(en-resource://database/7427:1)]
- 读取操作:没有任何同步控制和锁操作,理由就是内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。
-
优点:
- 读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。
-
缺点:
- 内存占用:毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC;
- 无法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。
- 大量写操作性能极差,性能开销较大。
讲一讲ThreadLocal的理解
-
数据结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zzzLSWJx-1598618731950)(en-resource://database/7407:1)] -
ThreadLocal提供了线程的局部变量,每个线程都可以通过set()和get()来对这个局部变量进行操作,但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离。
-
Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。
-
ThreadLocalMap有自己的独立实现,key是ThreadLocal的一个弱引用,value为代码中放入的值。每个线程在往ThreadLocal里放值的时候,都会往自己的ThreadLocalMap里存,读也是以ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
-
ThreadLocalMap有点类似HashMap的结构,只是HashMap是由数组+链表实现的,而ThreadLocalMap中并没有链表结构。我们还要注意Entry, 它的key是ThreadLocal<?> k ,继承自WeakReference, 也就是我们常说的弱引用类型。
ThreadLocal的内存泄漏和解决方案
- ThreadLocalMap 中使用的key为ThreadLocal的弱引用,而value是强引用
- 所以,如果ThreadLocal没有被外部强引用的情况下,在垃圾回收的时候,key会被清理掉,而value不会被清理掉。这样一来,ThreadLocalMap中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。
- ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉key为null的记录。使用完ThreadLocal方法后,最好手动调用remove()方法
ConcurrentLinkedQueue——高性能非阻塞列队(适合列队读写存在多个线程)
- Java 提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。
- 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。
- ConcurrentLinkedQueue使用链表作为其数据结构.ConcurrentLinkedQueue 应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。
- ConcurrentLinkedQueue 主要使用 CAS 非阻塞算法来实现线程安全。ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。
BlockingQueue
- 阻塞队列(BlockingQueue)被广泛使用在生产者-消费者问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
- BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。
- ArrayBlockingQueue:基于数组的有界队列实现类,一旦创建容量不能改变。其并发控制采用可重入锁来控制,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。ArrayBlockingQueue 默认情况下非公平。有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:private static ArrayBlockingQueue blockingQueue = new ArrayBlockingQueue(10,true);
- LinkedBlockingQueue:底层基于单向链表实现的阻塞队列,可以当做无界或有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE
- PriorityBlockingQueue:是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。PriorityBlockingQueue 并发控制采用的是 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。
线程池
线程池好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池执行execute()方法和submit()方法的区别是什么呢?
- execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功;
- submit()方法用于提交需要返回值的任务。线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否执行成功,并且可以通过 Future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
线程池的创建
- 通过Executor框架的工具类Executors来实现我们可以创建三种类型的ThreadPoolExecutor:(方法内部其实是调用了ThreadPoolExecutor的构造方法)
- 《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险Executors 返回线程池对象的弊端如下:
- FixedThreadPool和SingleThreadExecutor允许请求列队的长度为Integer.Max_value,可能堆积大量请求导致OOM
- CachedThreadPool和ScheduledThreadPool允许创建线程数量为Integer.Max_value,可能创建大量线程,导致OOM
-
通过ThreadPoolExecutor构造方法实现:
- corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
- maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
- workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话新任务就会被存放在队列中。
- keepAliveTime:当线程池中的线程数量大于corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
- unit : keepAliveTime 参数的时间单位。
- threadFactory :executor 创建新线程的时候会用到。
- handler :拒绝策略,如果当前同时运行的线程数量达到maximumPoolSize并且队列也已经被放满了任时,ThreadPoolTaskExecutor 定义一些策略:
- 抛出异常:ThreadPoolExecutor.AbortPolicy,抛出 RejectedExecutionException来拒绝新任务的处理。
- 使用调用者的线程来处理:ThreadPoolExecutor.CallerRunsPolicy
- 直接丢弃掉:ThreadPoolExecutor.DiscardPolicy
- 丢弃最早的未处理的任务请求:ThreadPoolExecutor.DiscardOldestPolicy
-
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9aYBgDIZ-1598618731952)(en-resource://database/7409:1)]
线程池原理分析
- 提交任务,判断核心线程数是否已满,未满就创建线程;
- 核心线程数满了,就看等待列队是否已满,未满就加入等待列队
- 等待列队满了,看是否到达线程池最大线程数,未到达就创建线程,否则按照拒绝策略处理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CRK8O5IX-1598618731954)(en-resource://database/7411:1)]
如果你提交任务时,线程池队列已满,这时会发生什么这里区分一下:
(1)如果使用的是无界队列 LinkedBlockingQueue,也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为 LinkedBlockingQueue 可以近乎认为是一个无穷大的队列,可以无限存放任务
(2)如果使用的是有界队列比如 ArrayBlockingQueue,任务首先会被添加到ArrayBlockingQueue 中,ArrayBlockingQueue 满了,会根据maximumPoolSize 的值增加线程数量,如果增加了线程数量还是处理不过来,ArrayBlockingQueue 继续满,那么则会使用拒绝策略RejectedExecutionHandler 处理满了的任务,默认是 AbortPolicy
Lock体系
乐观锁,悲观锁
- 定义
- 悲观锁: 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
- 乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
- 使用场景:
- 乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。
- 悲观锁适用多写的情况,一般会经常产生冲突
乐观锁常见的两种实现方式——版本号机制,CAS算法
-
版本号机制:
- 一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时在读取数据的同时也会读取version值,在提交更新时若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
-
CAS算法
- 不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步
CAS算法的缺点
- ABA问题:
- 如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。
- JDK1.5以后的AtomicStampedReference类就提供了此种能力,其中的compareAndSet方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 循环时间长开销大:
- 自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。
- 只能保证一个共享变量的原子操作:
- CAS只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
讲一讲自旋锁
- 自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
- 获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。
- 它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。
- 但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。
- java实现自旋锁[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TNbbRD1A-1598618731955)(en-resource://database/7429:1)]
- 存在的问题:
- 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
- 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题
CAS与synchronized的使用情景
- CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
- 补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
AQS
-
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面。 AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器。
-
AQS原理图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-akxRHJqB-1598618731956)(en-resource://database/7419:1)] -
AQS核心思想是:
- 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
- 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制。这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
- CLH队列是一个虚拟的双向列队,AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点Node来实现锁的分配
- AQS使用一个int成员变量(private volatile int state)来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。 状态信息通过protected类型的getState,setState,compareAndSetState进行操作
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WRNPEfgh-1598618731957)(en-resource://database/7421:1)]
-
AQS的两种资源共享方式
- Exclusive,独占:只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁;
- Share(共享):多个线程可同时执行,如Semaphore,CountDownLatch,CyclicBarrier,ReadWriteLock
-
AQS底层使用了模板方法模式,需要自定义同步器一般的方式是这样:
-
使用者继承AbstractQueuedSynchronizer并重写指定的方法,这些重写方法很简单,无非是对于共享资源state的获取和释放(如下)AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。
-
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-laBb0KNA-1598618731959)(en-resource://database/7423:1)]
-
将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
-
-
以ReentrantLock为例,state初始化为0表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
-
AQS组件: Semaphore(信号量) CountDownLatch (倒计时器) CyclicBarrier(循环栅栏)
并发工具类
CountDownLatch、CyclicBarrier、Semaphore
-
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FzNmNn6r-1598618731960)(en-resource://database/7431:1)]
-
CountDownLatch
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IExH68BG-1598618731962)(en-resource://database/7433:1)]
- CountDownLatch是一次性的
- count初始化CountDownLatch,然后需要等待的线程调用await方法。await方法会一直受阻塞直到count=0。而其它线程完成自己的操作后,调用countDown()使计数器count减1。当count减到0时,所有在等待的线程均会被释放
- 通过count变量来控制等待,如果count值为0了(其他线程的任务都完成了),那就可以继续执行。
- 它默认构造 AQS 的 state 值为 count。当线程使用countDown方法时,其实使用了tryReleaseShared方法以CAS的操作来减少state,直至state为0就代表所有的线程都调用了countDown方法。当调用await方法的时候,如果state不为0,就代表仍然有线程没有调用countDown方法,那么就把已经调用过countDown的线程都放入阻塞队列Park,并自旋CAS判断state == 0,直至最后一个线程调用了countDown,使得state == 0,于是阻塞的线程便判断成功,全部往下执行。
- 两种经典用法:
- 某一线程在开始运行前等待 n 个线程执行完毕。new CountDownLatch(n)
- 实现多个线程开始执行任务的最大并行性,强调的是多个线程在某一时刻同时开始执行,初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 :new CountDownLatch(1),多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。
-
CyclicBarrier——可重用
- CountDownLatch注重的是等待其他线程完成,CyclicBarrier注重的是:当线程到达某个状态后,暂停下来等待其他线程,所有线程均到达以后,继续执行。
- CyclicBarrier允许一组线程互相等待,直到到达某个公共屏障点。叫做cyclic是因为当所有等待线程都被释放以后,CyclicBarrier可以被重用(对比于CountDownLatch是不能重用的)
- CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。
- 应用场景:CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。
-
Semaphore(信号量)实际上就是可以控制同时访问的线程个数,它维护了一组"许可证"。(并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量)Semaphore 经常用于限制获取某种资源的线程数量。
- 当调用acquire()方法时,会消费一个许可证。如果没有许可证了,会阻塞起来当调用
- release()方法时,会添加一个许可证。
- 这些"许可证"的个数其实就是一个count变量罢了
CyclicBarrier 和 CountDownLatch 的区别
- CountDownLatch 是计数器,只能使用一次,而 CyclicBarrier 的计数器提供 reset 功能,可以多次使用。
- CountDownLatch: 一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;CyclicBarrier : 多个线程互相等待,直到到达同一个同步点,再继续一起执行。对于 CountDownLatch 来说,重点是“一个线程(多个线程)等待”,而其他的 N 个线程在完成“某件事情”之后,可以终止,也可以等待。而对于 CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。
- CountDownLatch 是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而 CyclicBarrier 更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。
原子操作类
JUC中Atomic原子类有哪些
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cTYnQkYl-1598618731963)(en-resource://database/7413:1)]
AtomicInteger的使用和原理
-
使用:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sFSCtMR1-1598618731965)(en-resource://database/7415:1)] -
原理:
- AtomicInteger类主要利用CAS+volatile+native方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
- UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。
- 另外 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QOqeFB81-1598618731966)(en-resource://database/7417:1)]