java-juc

java-juc

起男 632 2020-09-29

java-juc

线程的创建

  • 继承Thread类

    Thread类本质上是实现了Runnable接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过Thread类的start()实例方法。start()方法里有一个native方法,它将启动一个新线程,并执行run()方法。

  • 实现Runnable接口

    如果自己的类以及继承了一个类,就无法直接继承Thread,此时,可以实现一个Runnable接口

  • 实现Callable接口

    有返回值的任务必须实现Callable接口,类似的,无返回值的任务实现Runnable接口。执行Callable任务后,可以获取一个Future的对象,在该对象上调用get就可以获取到Callable任何返回的Object了。

  • 线程池

    线程和数据库连接这些资源都是非常宝贵的资源。那么每次需要的时候创建,不需要的时候销毁,是非常浪费资源的。那么我们就可以使用缓存的策略,也就是使用线程池

线程池

java里线程池的顶级接口是Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是ExecutorService

  • newCachedThreadPool

    创建一个可根据需要创建新线程的线程池,但是在以前构造的线程可用时将重用它们。对于执行狠毒短期异步任务的程序而言,这些线程池通常可提高程序性能。

    调用execute将重用以前构造的线程(如果可用)。如果现有线程没有可用的,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有60秒钟未被使用的线程。因此,长时间保持空闲的线程池不会使用任何资源。

  • newFixedThreadPool

    创建一个可复用固定线程数的线程池,以共享的无界队列方式来运行这些线程。

    如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务在队列中等待。

    如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务。

    在某个线程被显式的关闭之前,池中的线程将一直存在。

  • newScheduledThreadPool

    创建一个线程池,它可以安排在指定延迟后运行或者定期的执行

  • newSingleThreadExecutor

    创建一个线程的线程池,这个线程池可以在线程死后重新启动一个线程来替代原来的线程继续执行下去

生命周期

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。

线程启动后不可能一直占着cpu独自运行,所以cpu需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞之间切换

  • 新建状态(new)

    当程序使用new关键字创建了一个线程后,该线程就处于新建状态,此时仅由jvm为其分配内存,并初始化其成员变量的值

  • 就绪状态(runnable)

    当线程对象调用了start()方法后,该线程就处于就绪状态。jvm会为其创建方法调用栈和程序计数器,等待调度运行

  • 运行状态(running)

    如果处于就绪状态的线程获得了cpu,开始执行run()方法的线程执行体,则该线程处于运行状态

  • 阻塞状态(blocked)

    阻塞状态是指线程因为某种原因放弃了cpu使用权,也即让出了cpu时间片,暂时停止运行。直到线程进入可运行状态,才有机会再次获得cpu时间片转到运行状态

    • 等待阻塞(wait->等待队列):运行的线程执行wait方法,jvm会把该线程放入等待队列
    • 同步阻塞(lock->锁池):运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则jvm会把该线程放入锁池(lock pool)
    • 其他阻塞(sleep/join):运行的线程执行sleep()或join()方法,或者发出了io请求时,jvm会把该线程置为阻塞状态。当sleep状态超时、join等待线程终止或超时、或者io处理完毕时,线程重新转入可运行状态
  • 死亡状态(dead)

    • 正常结束:run或call方法执行完成,线程正常结束
    • 异常结束:线程抛出一个未捕获的exception或error
    • 调用stop:直接调用线程的stop方法来结束该线程,该方法容易导致死锁,不推荐使用

终止线程

  • 正常运行结束

    程序运行结束,线程自动结束

  • 使用退出标志退出线程

    使用一个变量来控制循环

  • interrupt方法结束线程

    • 线程处于阻塞状态:调用interrupt方法时会抛出InterrupptException异常,捕获异常,然后break跳出循环,从而让我们结束这个线程。
    • 线程处于未阻塞状态:使用isInterrupted方法判断线程的中断标志来退出循环。当使用interrupt方法时,中断标志就会为true
  • stop方法结束线程(线程不安全)

    程序中可以直接使用stop方法来强行终止线程,但是stop方法是很危险的,就像突然关闭计算机电源,而不是按正常程序关机一样,可能会产生不可预料的结果

    stop之后,创建子线程的线程就会抛出ThreadDeatherror的错误,并且会释放子线程所持有的所有锁。一般加锁的代码块,都是未来保护数据一致性,如果在调用stop后导致了该线程所持有的锁突然释放,就可能导致不一致

sleep与wait

  1. sleep方法是属于Thread类中的。而wait方法是属于Object类的
  2. sleep方法导致了程序暂停指定的时间,让出cpu给其他线程,但是它的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态
  3. 在调用sleep方法的过程中,线程不会释放对象锁
  4. 而当调用wait方法的时候,线程会放弃对象锁,进入等待此对象的等待线程池,只有针对此对象调用notify方法后本线程才进入对象锁定池准备获取对象锁进入运行状态

start与run

  1. start方法用来启动线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码
  2. 通过调用Thread类的start方法来启动一个线程,这时此线程是处于就绪状态,并没有运行
  3. 方法run称为线程体,它包含了要执行的这个线程的内容,线程就进入运行状态,开始运行run函数当中的代码。run方法运行结束,此线程终止。然后cpu再调度其它线程

后台线程

定义

守护线程也称“服务线程”,它是后台线程,它由一个特性,即为用户线程提供公共服务,在没有用户线程可以服务时会自动离开

优先级

守护线程的优先级比较低,用于为系统中的其它对象和线程提供服务

设置

通过setDaemon(true)来设置线程为“守护线程”

将一个用户线程设置为守护线程的方式是在线程对象创建之前,调用线程对象的setDaemon方法

生命周期

守护进程是运行在后台的一种特殊进程。它独立于控制终端并且周期性的执行某种任务或等待处理某些发生的事件。也就是说守护进程不依赖终端,但是依赖于系统,于系统同生共死。当jvm中所有线程都是守护线程的时候,,jvm就可以退出了。如果还要一个或以上的非守护线程则jvm不会退出

示例

垃圾回收线程就是有一个经典的守护线程,当我们的程序中不再有任何运行的thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是jvm上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中可回收资源。

在守护线程中产生的新线程也是守护线程

乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁。但是在更新的时候会判断一下在此期间别人没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作

java中的乐观锁基本都是通过cas实现的,cas是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败

悲观锁

悲观锁是一种悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会阻塞直到锁。

java中的悲观锁就是synchronized,aqs框架下的锁则是先尝试cas乐观锁去获取锁,获取不到才会转为悲观锁

自旋锁

自旋锁原理非常简单,如果持有锁的线程能在很短事件内释放资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程的内核的切换消耗

线程自旋是需要消耗cpu的,说白了就是让cpu在做无用功,如果一直获取不到锁,那线程也不能一直占用cpu自旋做无用功,所以需要设定一个自旋等待的最大时间

如果持有锁的线程执行的时间超过自旋等待的最大时间没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态

优缺点

自旋尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能大幅提升,因为自旋锁的消耗会小于线程阻塞挂起再唤醒的操作消耗,这些操作会导致线程发生两次上下文切换

但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁,这时就不适合使用自旋锁了,因为自旋锁再获取锁前执行都占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁

时间阈值

自旋锁的目的是为了占着cpu的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用cpu资源,进而会影响整体的性能。因此自旋的周期选择额外重要

jvm对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时jvm还针对当前cpu的负荷情况做了较多的优化,如果平均负载小于cpus则一直自旋,如果有超过(cpus/2)个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现Owner发生了变化则延迟自旋时间或进入阻塞,如果cpu处于节电模式则停止自旋,自旋时间的最坏情况是cpu的存储延迟(cpu a存储了一个数据,到cpu b得知这个数据直接的时间差),自旋时会适当放权线程优先级直接的差异

自旋锁的开启

jdk6中

  • -XX:+UseSpinning开启
  • -XX:PreBlockSpin=10 为自旋次数

jdk7之后

  • 去掉此参数,由jvm控制

synchronized同步锁

synchronized可以把任意一个非null的对象当作锁。

他属于独占式的悲观锁,同时属于可重入锁。

作用范围

  • 作用于方法时,锁住的是对象的实例(this)
  • 作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久代(jdk8后叫元数据区),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程
  • 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监听器的时候,对象监听器会将这些线程存储在不同的容器中

核心组件

  • Wait Set:哪些调用wait方法被阻塞的线程被放置在这里
  • Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中
  • Entry List:Contention List中哪些有资格成为候选资源的线程被移动到Entry List中
  • OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被称为OnDeck
  • Owner:当前已经获取到资源的线程
  • !Owner:当前释放锁的线程

实现

  1. jvm每次从队列的尾部取出一个数据用于锁竞争候选者OnDeck,但是并发情况下,ContentionList会被大量的并发线程进行cas访问,为了降低对尾部元素的竞争,jvm会将一部分线程移动到EntryList中作为候选竞争线程
  2. Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)
  3. Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权力交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在jvm中,也把这种选择行为称之为”竞争切换“
  4. OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁的资源仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中
  5. 处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作操作系统来完成的
  6. synchronized是非公平锁,在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,者明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源
  7. 每个对象都有个monitor对象,加锁就是在竞争monitor对象,代码块加锁是在前后分别加上monitorenter和monitorexit指令来实现的,方法加锁是通过一个标记位来判断的
  8. synchronized是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作的时间更多
  9. java6,synchronized进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后推出的java7和8中,均对该关键字的实现机理做了优化。引入了轻量级锁和偏向锁。都是在对象头中有标记位,不需要经过操作系统加锁。
  10. 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀
  11. jdk6中默认是开启偏向锁和轻量级锁,可以通过 -XX:-UseBiasedLocking来禁用偏向锁

ReentrantLock

ReentantLock继承接口Lock并实现了接口中直到的方法,他是一种可重入锁,除了能完成synchronized所能完成的工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等。避免多线程死锁的方法

主要方法

  • lock:执行此方法时,如果锁处于空闲状态,当前线程将获取到锁,相反,如果锁已经被其他线程持有,将禁用当前线程,直到当前线程获取到锁

  • tryLock:如果锁可用,则获取锁,并立刻返回true,否则返回false。该方法和lock的区别在于,tryLock只是试图获取锁,如果锁不可用,不会导致当前线程被禁用,当前线程仍然继续往下执行代码。而lock方法则一定要获取到锁,如果锁不可用,就一直等待,在未得到锁之前,当前线程并不继续向下执行。

  • unlock:执行此方法时,当前线程将释放持有的锁。锁只能由持有者释放,如果线程并不持有锁,却执行该方法,可能导致异常的发生

  • newCondition:条件对象,获取等待通知组件。该组件和当前。该组件和当前的锁绑定,当前线程只有获取了锁,才能调用该组件的await方法,而调用后,当前线程将缩放锁。

  • getHoldCount:查询当前线程保持此锁的次数,也就是执行此线程执行lock方法的次数

  • getQueueLength:返回正等待获取此锁的线程估计数

    比如启动了10个线程,1个线程获得锁,此时返回的是9

  • getWaitQueueLength:返回等待于此锁相关的给定条件的线程估计数

    如果10个线程,用同一个condition对象,并且此时者10个线程都指向了condition的await方法,那么此时,返回的是10

  • HasWaiters:查询是否有线程等待与此锁有关的给定条件,对于指定contidion对象,有多少线程执行了await方法

  • hasQueuedThread:查询给定线程是否等待获取此锁

  • hasQueuedThreads:是否有线程等待此锁

  • isFair:该锁是否是公平锁

  • isHeldByCurrentThread:当前线程是否保持锁定,线程执行lock方法的前后分别是false和true

  • isLock:此锁是否有任意线程占用

  • lockInterruptibly:如果当前线程未被中断,获取锁

非公平锁

jvm按随机,就近原则分配锁的机制称为非公平锁,ReentrantLock在构造函数中提供了是否公平锁的初始化方式,默认为非公平锁。非公平锁实际效率要远超公平锁,触发程序有特殊需要,否则使用非公平锁的分配机制

公平锁

公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先分配到锁,ReentrantLock在构造函数中提供了是否使用公平锁的初始化

与synchronized的共同点

  • 都是用来协调多线程共享对象,变量的访问
  • 都是可重入锁,同一线程可以多次获得同一个锁
  • 都保证了可见性和互斥性

与synchronized的区别

  • ReentrantLock通过方法lock与unlock来进行加锁与解锁操作,与synchronized会被jvm自动解锁机制不同,ReentrantLock加锁后需要手动进行解锁。为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作
  • ReentrantLock相比synchronized的优势是可中断、公平锁、多个锁
  • ReentrantLock是api级别的,synchronized是jvm级别的
  • ReentrantLock可以实现公平锁
  • ReentrantLock通过Condition可以绑定多个条件

Condition类和Object类的锁方法

  • Condition类的awiat方法和Object类的wait方法等效
  • Condition类的signal方法和Object类的notify方法等效
  • Condition类的signalAll方法和Object类的notifyAll方法等效
  • ReentrantLock类可以唤醒指定条件的线程,而Object的唤醒是随机的

tryLock和lock和lockInterruptibly的区别

  • tryLock能获得锁就返回true,不能就立刻返回false。还可以设置时间限制,如果超过该时间限制,返回false
  • lock能获得锁就返回true,不能的话一直等待获得锁
  • lock和lockInterruptibly,如果两个线程分别执行这个两个方法,但此时中断者两个线程,lock不会抛出异常,而lockInterruptibly会抛出异常

信号量

semaphore是一种基于计数的信号量。它可以设定一个阈值,基于此,多个线程竞争获取许可信号,做完自己的申请后归还,找过阈值后,线程申请许可信号将会被阻塞。

实现互斥锁

可以创建计数为1的semaphore,将其作为一种类似互斥锁的机制,者也叫二元信号量,标识两种互斥状态

常用方法

方法 作用
acquire() 获取一个许可,若无许可,则一直等待
acquire(int permits) 获取permits个许可
release() 释放许可(在释放前,必须先获得许可)
release(int permits) 释放permits个许可
tryAcquire() 尝试获取一个许可,若获取成功,则立刻返回true
tryAcquire(long timeout,TimeUnit unit) 尝试获取一个许可,若在指定的时间内获取成功,则立刻返回true
tryAcquire(int permits,long timeout,TimeUnit unit) 尝试获取permits个许可,若在指定的时间内获取成功,则立刻返回true
availablePermits() 得到可用许可的数目

Semaphore与ReentrantLock

semaphore基本能完成ReentrantLock的所有工作,使用方法也与之类似,通过acquire与release方法来获得和是否临界资源。经实测,acquire方法默认为可响应中断锁,与ReentrantLock的lockInterruptibly作用一致,也就是说在等待临界资源的过程中可以被thread的interrupt方法中断

semaphore也实现了可轮询的锁请求与定时锁的功能,除了方法名tryAcquire与tryLock不同,其使用方法与ReentrantLock几乎一致。Semaphore也提供了公平与非公平锁的机制,也可在构造函数中进行设定

semaphore的锁释放操作也有手动进行,因此与ReentrantLock一样,为避免线程寅抛出异常而无法正常释放锁的情况发生,释放锁的操作必须在finally中完成

原子变量

常见的原子操作有,AtomicInteger、AtomicBoolean、AtomicILong、AtomicReference等,它们的实现原理相同,区别在于运算对象类型的不同。还可以通过AtomicReference将一个对象的所有操作转化成原子操作。

在程序中,诸如i和i等运算不具有原子性,是不安全的线程操作之一。通常可以使用synchronized将该操作变成一个原子操作,但jvm为此类操作特意提供了一些同步类,使得使用更方便,且效率也变得更高

可重入锁

可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取锁的代码,但不受影响。在java环境下ReentrantLock和synchronized都是可重入锁

公平锁和非公平锁

公平锁(fair)

加锁前检查是否有排队等待的线程,优先排队等候的线程,先来先得

非公平锁(nonfair)

加锁时不考虑排队等待问题,直接尝试取锁,获取不到自动到队尾等待

  1. 非公平锁性能比公平锁高5-10倍,因为公平锁需要在多核的情况下维护一个队列
  2. java的synchronized是非公平锁,ReentrantLock默认采用的是非公平锁

读写锁

为了提高性能,java提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制。在没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。

读写锁分为读锁和写锁,多个读锁不互斥,读锁和写锁互斥,者是由jvm控制的

读锁

如果你的代码只是读数据,可以很多人同时读,但不能同时写,那就上读锁

写锁

如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。总之,读的时候上读锁,写的时候上写锁

java中读写锁有个接口java.util.concurrent.locks.ReadWriteLock,也有具体的实现ReentrantReadWriteLock

共享锁和独占锁

独占锁

独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock就是以独占方式实现的互斥锁。

独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性

共享锁

共享锁则允许多个线程同时获取锁,并发访问共享资源,如:ReadWriteLock。

共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源

  • AQS的内部类node定义了两个常量shared和exclusive,它们分别标识aqs队列中等待线程的锁获取模式
  • java的并发包中提供了ReadWriteLock,读写锁。它允许一个资源可以被多个读操作访问,或者被一个写操作,但不能同时进行。

重量级锁

synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。

但是监视器锁本质又是依赖于底层的操作系统的Mutex lock来实现的。

而操作系统实现线程之间的切换这就需要从用户态转换到内核态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么synchronized效率低的原因。

因此,这种依赖于操作系统Mutex lock所实现的锁我们称之为“重量级锁”

jdk中对synchronized做的种种优化,其核心堆是为了减少这种重量级锁的使用。

jdk6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”

轻量级锁

锁的状态共有四种:无锁、偏向锁、轻量级锁、重量级锁

锁升级

随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。

需要强调的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。

轻量级锁所适用的常见是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

偏向锁

偏向锁的目的是在某个线程获得锁之后,消除这个线程重入(cas)的开销,看起来让这个线程得到了偏护。

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次cas原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次cas原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的cas原子指令的性能消耗)。

轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

锁优化

  • 减少锁持有时间:只在有线程安全要求的程序上加锁
  • 缩小锁粒度:将大对象拆成小对象,以增加并行度,降低锁竞争。降低了锁的竞争、偏向锁、轻量级锁成功率才会提高。
  • 锁分离:最常见的锁分离就是读写锁ReadWriteLock,根据功能分离成读锁和写锁,这样读读不互斥、读写互斥、写写互斥,即保证了线程安全,又提高了性能。
  • 锁粗化:一般情况下,为了保证多线程间有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立刻释放锁。但对同一个锁不停的请求、同步和释放,其本身也会消耗很多资源。
  • 锁消除:是发生在编译器级别的事情。在即时编译时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。

基本方法

方法名 作用
wait 线程等待,调用该方法的线程进入waiting状态,只有等待另外线程的通知或被中断才会返回,需要注意的是调用wait方法后,会释放对象的锁。因此,wait方法一般用在同步方法或同步代码块中。
使用时需要当前线程持有锁
sleep 线程休眠,与wait方法不同的是sleep不会释放当前占有的锁。sleep(long)会导致线程进入timed-wating状态,而wait方法会导致当前线程进入wating状态
yield 线程让步,使当前线程让出cpu时间片,与其他线程一起重新竞争cpu时间片,一般情况下,优先级高的线程有更大的可能性成功竞争到cpu时间片,但这不是绝对的,有的操作系统对线程优先级并不敏感
interrupt 线程中断,中断一个线程,其本意是给这个线程一个通知信号,会影响这个线程内部的一个中断标识。这个线程本身并不会因此而改变状态
1. 调用interrupt方法并不会中断一个正在运行的线程。也就是说处于running状态的线程并不会因为被中断而被终止,仅仅改变了内部维护的中断标识位而已
2.若调用sleep而使线程处于timed-wating状态,这时调用interrupt方法,会抛出InterruptedException,从而使线程提前结束timed-wating状态
3.许多声明抛出InterruptedException的方法(如sleep、wait),抛出异常前,都会清除中断标识,所以抛出异常后,调用isInterrupted方法返回false
4.中断状态是线程固有的一个标识,可以通过此标识安全的终止线程安全的终止线程。比如,你想终止一个线程thread的时候,可以调用thread.interrupt()方法,在线程的run方法内部可以根据thread.isInterrupted()的值来优雅的终止线程
notify 线程唤醒,唤醒在此对象监视器上等待的单个线程,如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意的
notifyAll 线程唤醒,唤醒才此对象监视器上的所有线程
isAlive 判断一个线程是否存活
join 等待其它线程终止,在当前线程中调用一个线程的join方法,则当前线程转为阻塞状态,等到另一个线程结束,当前线程再由阻塞变为就绪状态
activeCount 获取线程组中活跃的线程数
enumerate 将线程组中的线程复制到指定数组,并返回线程数量
currentThread 得到当前线程
isDaemon 一个线程是否为守护线程
setDaemon 设置一个线程为守护线程
setName 为线程设置一个名称
setPriority 设置一个线程的优先级
getPriority 获取一个线程的优先级

线程上下文切换

利用时间片轮转的方式,cpu给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务,任务的状态保存及加载,这段过程就叫做上下文切换。

时间片轮转的方式使多个任务在同一颗cpu上执行变成了可能

进程

指一个程序运行的实例。

在linux系统中,线程就是能并行运行并且与他们的父进程共享同一地址空间和其它资源的轻量级的进程

上下文

是指某一时间点cpu寄存器和程序计数器的内容

寄存器

是cpu内部的数量较少但是速度很快的内存。寄存器通过对常用值的快速访问来提高计算机程序的运行速度

程序计数器

是一个专用的寄存器,用于表明指令序列中cpu正在执行的位置,存的值为正在执行的命令的位置或者下一个将要被执行的指令的位置,具体依赖于特定的系统

切换帧

上下文切换可以认为是内核在cpu上对于进程进行切换,上下文切换过程中的信息是保存在进程控制块中的。进程控制块还经常被称为“切换帧”。信息会一直保存到cpu的内存中,直到他们被再次使用。

上下文切换的过程

  1. 挂起一个进程,将这个进程在cpu中的状态存储于内存中的某处
  2. 在内存中检索下一个进程的上下文并将其在cpu的寄存器中恢复
  3. 跳转到程序计数器所指向的位置(程序被中断时的代码行),以恢复该进程的运行

引起上下文切换的原因

  • 当前执行任务的时间片用完之后,系统cpu正常调度下一个任务
  • 当前执行任务碰到io阻塞,调度器将此任务挂起,继续下一任务
  • 多个任务抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续下一个任务
  • 用户代码挂起当前任务,让出cpu时间
  • 硬件中断

同步锁于死锁

同步锁

当多个线程同时访问一个数据时,很容易出现问题。为了避免出现这种情况,我们要保证线程同步互斥,就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。

java中可以使用synchronized关键字来获取一个对象的的同步锁

死锁

何为死锁,就是多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放

线程池

原理

线程池做的工作主要是控制线程的数量,除了过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等待其它线程执行完毕,再从队列中取出任务来执行。

特点:线程复用、控制最大并发数、管理线程

线程复用

每一个thread对象都有一个start方法。当调用start启动线程时,java虚拟机会调用该类的run方法。该类的run方法中调用了runnable的run方法。我们可以继承thread类,再其start方法中添加不断循环调用传递过来的runnalbe对象。这就是线程池的实现原理。

循环方法中不断获取runnable的用queue实现的,在获取下一个runnalbe之前可以是阻塞的。

组成

  • 线程池管理器:用于创建并管理线程池
  • 工作线程:线程池中的线程
  • 任务接口:美国任务必须实现的接口,用于工作线程调度其执行
  • 任务对象:用于存放待处理的任务,提供一种缓存机制

ThreadPoolExecutor构造方法

  • corePoolSize:指定了线程池中的线程数量
  • maximumPoolSize:指定了线程池中的最大线程数量
  • keepAliveTime:当前线程池数量超过corePoolSize时,多余的空闲线程的存活时间
  • unit:keepAliveTime的单位
  • workQueue:任务队列,存放被提交但尚未被执行的任务
  • threadFactory:线程工厂,用于创建线程
  • handler:拒绝策略,当任务太多来不及处理,如何拒绝任务

拒绝策略

线程池中的线程已经用完了,无法继续为新任务服务,同时等待队列也满了,再也塞不下新任务了。这个时候就需要拒绝策略来处理这个问题

jdk内置拒绝策略:

  • AbortPolicy:直接抛出异常,阻止系统正常运行。
  • CallerRunsPolicy:只要线程池关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能有可能会极具下降
  • DiscardOldestPolicy:丢弃最早的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务
  • DiscardPolicy:该策略默默的丢弃无法处理的任务,不予任何处理

拒绝策略均实现了RejectedExecutionHandler接口,若以上策略仍无法满足需要,可以自己扩展RejectedExecutionHandler接口

工作过程

  1. 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里有任务,线程池也不会马上执行它们
  2. 当调用execute方法添加一个任务时,线程池会做如下判断:
    1. 如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务
    2. 如果整形运行的线程数量大于或等于corePoolSize,那么将这恶鬼任务放入队列
    3. 如果队列满了,而且正在运行的线程数量小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务
    4. 如果队列满了,而且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会抛出异常RejectExecutionException
  3. 当一个线程完成任务时,它会从队列中取下一个任务来执行
  4. 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于corePoolSize,那么这个线程就会被停掉。所有线程池所有任务完成后,它最终会收缩到corePoolSize的大小

阻塞队列

线程阻塞的两种情况:

  • 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞,直到有数据放入队列
  • 当队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞,直到队列中有空的位置

java中的阻塞队列

名称 作用
ArrayBlockingQueue 由数组结构组成的有界阻塞队列
LinkedBlockingQueue 由链表结构组成的有界阻塞队列
PriorityBlockingQueue 支持优先级排序的无界阻塞队列
DelayQueue 使用优先级队列实现的阻塞队列
SynchronousQueue 不存储元素的阻塞队列
LinkedTransferQueue 由链表结构组成的无界阻塞队列
LinkedBlockingDeque 由链表结构组成的双向阻塞队列

ArrayBlockingQueue(公平、非公平)

用数组实现的有界阻塞队列。

此队列按照先进先出(fifo)的原则对元素进行排序。

默认情况下不保证访问者公平的访问队列,所谓公平访问队列是指阻塞的所有生产者线程或消费者线程,当队列可用时,可用按照阻塞的先后顺序访问队列,即先阻塞生产者线程,可用先往队列里插入元素,再阻塞的消费者线程,可用先从队列里获取元素。

通常为了保证公平性会降低吞吐量

LinkedBlockingQueue(两个独立锁提高并发)

基于链表的阻塞队列。

此队列按照先进先出(fifo)的原则对元素进行排序。

LinkedBlockingQueue之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用独立的锁来控制数据同步,者也意味着在高并发的情况下生产这和消费者可以并行的操作队列中的数据,以此来提高整个队列的并发性能

PriorityBlockingQueue(compareTo排序实现优先级)

是一个支持优先级的无界队列。

没人情况下元素采取自然顺序升序排列。

可以自定义实现compareTo方法来指定元素进行排序的规则,或者初始化时,指定构造参数Comparator来对元素进行排序。

注:不能保证同级元素的顺序

DelayQueue(缓存失效、定时任务)

是一个i支持延时获取元素的无界阻塞队列。

队列使用PriorityQueue来实现。

队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。

应用场景

  • 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素时,标识缓存有效期到了。
  • 定时任务调度:使用DelayQueue保存当天会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行,如TimerQueue

SynchronousQueue(不保存数据、可用于传递数据)

是一个不存储元素的阻塞队列。

每一个put操作必须等待一个take操作,否则不能继续添加元素。

synchronousQueue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。

队列本身并不存储任何元素,非常适合用于传递性场景,比如在一个线程中使用的数据,传递给另外一个线程使用,SynchronousQueue的吞吐量高于LinkedBlockingQueue和ArrayBlockingQueue

LinkedTransferQueue

是一个由链表结构组成的无界阻塞队列。

相对于其他阻塞队列,LinkedThransferQueue多路tryTransfer和transfer方法。

  • transfer:如果当前有消费者正在等待接收元素(消费者使用take方法或带有时间限制的poll方法时),transfer方法可以把生产者传入的元素立刻传输给消费者。如果没有消费者在等待接收元素,transfer方法会将元素存放在队列的tail节点,并等到该元素被消费者消费了才返回。

  • tryTransfer:是用来试探下生产者传入的元素是否能直接传给消费者。如果没有消费者等待接收元素,则返回false。和transfer方法的区别是无论消费者是否接受,方法立即返回。而transfer必须等到消费者消费了才返回。

    如果设置过期时间,如果超时还没有消费元素,则返回false

LinkedBlockingDeque

是一个由链表结构组成的双向阻塞列表。

所谓双向队列指的是你可以从队列的两端插入和移除元素。

双端队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。

相比其他的阻塞队列,LinkedBlockingDeque多了addFirst、addLast、offerFirst、offerLast、peekFirst、peekLast等方法,以First单词结尾的方法,表示插入、获取或移除双端队列的第一个元素。以Last结尾的方法,表示插入,获取或移除双端队列的最后一个元素。

在初始化LinkedBlockIngDeque时可以设置容量防止其过度膨胀。

volatile关键字

java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。

volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值

特性

  • 变量可见性:其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可用立刻获取的
  • 禁止重排序:volatile禁止了重排序指令

和非volatile变量相比

当对非volatile变量进行读写的时候,每个线程先从内存拷贝变量到cpu缓存中。如果计算机有多个cpu,每个线程可能在不同的cpu上被处理,这意味着每个线程可以拷贝到不同的cpu缓存中。而声明变量是volatile的,jvm保证了每次读取变量都从内存中读取,跳过了cpu缓存这一步

和sychronized相比

在访问volatile变量时不会执行加锁操作,因此也就不会让执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

适用场景

volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i这种操作的原子性,因为本质上i是读、写两次操作。在某些场景下可以代替synchronized,但是不能完全取代。

  • 对变量的写操作不依赖于当前值,或者说是单纯的变量赋值
  • 该变量没有包含在具有其他变量的不变式中,也就是说,不同的volatile变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能适用volatile

ConcurrentHashMap

ConcurrentHashMap是由Segment(段)数组结构和HashEntry数组结构组成。

segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则是用于存储键值对数据。

一个ConcurrentHashMap里包含一个segment数组,segment是结构和HashMap类似,是一种数组和链表的结构,一个segment里包含一个hashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁

分段锁

ConcurrentHashMap内部分为了若干小的HashMap,称之为段(Segment)。

默认情况下一个iConcurrentHashMap被分为16个Segment,既锁的并发度。

如果需要在ConcurrentHashMap中添加一个新的数据,并不是将整个HashMap加锁,而是首先根据hashCode得到该数据存到哪个Segment中,然后对该段加锁,并完成put操作。

在多线程环境中,如果多个线程同时进行put操作,只要被加入的数据不放在同一个Segment中,则线程间可以做到真正的并行。

减小颗粒度

减小颗粒度是指缩小锁定对象的范围,从而减小锁冲突的可能性,从而提高系统的并发能力。

减小颗粒度是一种削弱多线程锁竞争的有效手段,这种技术典型的应用是ConcurrentHashMap的实现。

对于HashMap而言,最重要的两个方法是get和put方法,如果我们对整个HashMap加锁,可以得到线程安全的对象,但是加锁粒度太大。

Segment的大小也被称为ConcurrentHashMap的并发度

CoypOnWriteArrayList

CoypOnWriteArrayList底层也是使用一个数组来存放数据的,在读写方法时,读操作是不加锁的,写操作需要使用一个ReentrantLock来加锁,从而对多个写线程进行同步,同时底层数组也是使用volatile修饰的,则保证了读写线程之间的可见性。此外,CopyOnWriteArrayList的迭代器不是fail-fast的,即多线程操作不会印象迭代器的数据遍历。

fail-fast机制是java集合中的一种错误机制。当多个线程对同一个集合的内容进行操作,就可能会产生fail-fast事件。例如:当某一个线程a通过iterator去遍历某个集合的过程中,若该集合的内容被其他线程所改变;那么线程a访问集合时,就会抛出ConcurrentModificationException异常,产生fail-fast事件。

读操作

读操作是直接从内部存放数据的数组读取数据的,不需要加锁。

迭代器

CopyOnWriteArrayList在返回一个迭代器的时候,会基于创建这个迭代器的时候,内部数组所拥有的数据,创建一个该内部数组的快照,然后迭代器遍历的是该快照,而不是内部的数组。所以这种实现方式存在一定的数据延迟,即对其他线程并行的数据不可见。不过CopyOnWriteArrayList是基于写操作很少的场景的,所以这种实现方式是可行的。

因为迭代器遍历的是内部数组的快照,故与ArrayList迭代器不同的是CopyOnWniteArrayList的迭代器是不支持写操作的

写操作

写操作是需要通过ReentrantLock这个互斥锁来进行加锁的,然后会创建一个新的数组来替换原来的数组。

由于写操作很少,所以对于添加元素,新数组大小递增1,这个与ArrayList的每次扩容为原来的1.5被不一样。

对于删除元素,新数组大小递减1。

CopyOnWriteArraySet

CopyOnWriteArraySet是基于CopyOnWriteArrayList实现的一个set集合,内部不包含重复元素,也是线程安全的。

内部包含一个CopyOnWriteArrayList引用,而不是继承于CopyOnWriteArrayList来实现

添加

CopyOnWriteArraySet的核心实现为:add添加元素时,避免元素重复,同时需要考虑多线程同时添加的问题。

主要是基于CopyOnWriteArrayList的addIfAbsent实现:主要通过加锁成功之后,再次获取底层数组来判断释放需要添加,因为加锁成功之后,只有当前线程可以访问职工底层数组,同时由于数组是volatile的,所以可以保证多线程可见。

线程调度

抢占式

抢占式调度指的是每条线程执行的时间,线程的切换都由系统控制,系统控制指的是在系统某种运行机制下,可能每条线程都分同样的执行时间片,也可能是某些线程执行的时间片较长,甚至某些线程得不到执行的时间片。在这种机制下,一个线程的阻塞不会导致整个进程阻塞

协同式

协同式调度指某一线程执行完后主动通知系统切换到另一线程上执行,这种模式就像接力赛一样,一个人跑完自己的路就把接力棒交给下一个人,下个人继续往下跑。线程的执行时间由线程本身控制,线程切换可以预知,不存在多线程同步问题,但有一个致命弱点:如果一个线程编写有问题,运行到一半就一直阻塞,那么可能导致整个系统崩溃

CAS

概念

cas(compare and swap/set)比较并交换。

cas操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。

当多个线程同时使用cas操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。

失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

基于这样的原理,cas操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行处理。

过程

它包含三个参数:要更新的变量(内存值)、预期值(旧值)、新值

仅当内存值等于旧值式,才会将内存值设置为新值,如果内存值和旧值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,cas返回当前内存值。

atomic包

jdk5的原子包:java.util.concurrent.atomic这个包里面提供了一组原子类。

基本特性:在多线程环境下,当有多个线程执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由jvm从等待队列中选择一个线程进入。

ABA问题

cas会导致“aba问题”。cas算法实现一个重要前提是需要取出内存中某时刻的数据,而在下一时刻比较并替换,那么在这个时间差内会出现数据的变化

比如说一个线程一取出值a,另一个线程二也取出值a,并改成了b,然后二又将数据变成了a,这时线程一进行cas操作发现值依旧是a,然后线程一操作成功。

部分乐观锁的实现是通过版本号来解决aba问题的,乐观锁每次执行修改操作时,都会带上一个版本号,一但版本号和数据的版本号一致就可以进行修改并对版本号+1。

相对于synchronized这种阻塞算法,cas是非阻塞算法的一种常见实现。由于一般cpu切换时间比cpu指令集操作时间更长,所以性能有所提升

AQS

AbstractQueuedSynchronizer类如其名,抽象的队列式同步器,aqs定义了一套多线程访问共享资源的同步器框架,许多同步类都依赖于它,如RennetrantLock、Semaphore、CountDownLatch

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

aqs只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现。

之所以定义成abstract,是因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成接口,那么每个模式也要去实现另一个模式下的接口。

不同的自定义同步器争用共性资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取和释放即可,至于具体的等待队列的维护,aqs已经在顶层实现好了。

自定义同步器实现的主要方法

方法 描述
isHeldExclusively 该线程释放正在独占资源,只有用到condition才会需要去实现它
tryAcquire 独占方式,尝试获取资源,成功返回true,失败返回false
tryRelease 独占方式,尝试四分资源,成功返回true,失败返回false
tryAcquireShared 共享方式,尝试获取资源。负数表示失败;0表示成功,但没有可用资源;整数表示成功,且有剩余资源
tryReleaseShared 共享方式,尝试释放资源,如果释放后允许唤醒后续等待节点返回true,否则返回false

共享资源方式

  • exclusive:独占资源,只有一个线程能执行,如ReentrantLock
  • share:共享资源,多个线程可以同时执行,如Semaphore、CountDownLatch

同步器的实现

同步器是实现是aqs的核心,一ReentrantLock为例,state初始化为0,表示未锁定状态。a线程lock时,会调用tryAcquire独占该锁并state+1。此后其他线程再tryAcquire时就会失败,直到a线程unlock到state=0为止,其他线程才有获取锁的机会。

释放锁之前a线程自己是可用重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证state回到0的状态。

一般来说,自定义同步器要摸是独占的,要么是共享的,它们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但aqs也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock