并发编程那点儿事

目录

线程理论

线程和进程的区别

进程

进程是操作系统分配资源的最小单位,每个进程都是一个在运行中的程序,在windows中一个运行的xx.exe就是一个进程,他们都拥有自己独立的一块内存空间,一个进程可以有多个线程

线程

线程是操作系统调度的最小单元,负责当前进程中程序的执行,一个进程可以运行多个线程,多个线程之间可以共享数据

  1. 本质区别:进程是操作系统资源分配的最小单元,线程是处理器任务调度和执行的最小单位
  2. 资源开销:每个进程都有独立的代码和数据空间,进程之间的切换会有较大的开销,线程可以看做轻量级的进程

进程间通讯

管道

管道是内核管理的一个缓冲区,相当于内存中一个的小纸条,管道的一端连接着一个进程的输入,一端连接着另一个进程的输出,当管道中没有信息的话,从管道中读的进程会阻塞,直到另一端的进程放入信息,当管道中信息放满时,尝试放入信息的进程会阻塞,直到另一个端进程取出信息,当两个进程都结束时,管道也就结束了

特点:单向的,一端输入一端输出,采用先进先出FIFO模式,大小4K,满时写阻塞,空时读阻塞

分类:普通管道(仅父子进程间通讯)位于内存,命名管道位于文件系统,没有情缘关系的管道只要知道管道名也可以通讯

消息队列

消息队列可以看做是一个消息链表,只要线程有足够的权限,就可以往消息队列里面存消息和取消息,他独立于发送进程和接收进程,提供了一种从一个进程向另一个进程发送数据块的方法,每一个数据块都有一个消息类型(频道)和消息内容(节目),每个类型相互不受影响,类似于一个独立的管道

特点:全双工可读可写,生命周期跟随内核,每个数据块都有一个类型,接收者可以有不同的类型值,每个消息的最大长度是有上限的(MSGMAX),字节数也是有上限的(MSGMNB),系统上的消息队列总数也是有上限的(MSGMNI),

信号量

信号量本质上是一个计数器,它不以传送数据为目的,主要是用来保护共享资源,使得资源在一个时刻只有一个进程独享

原理:信号量只有等待和发送两种操作,即P(sv)和V(sv)两个操作都属于原子操作

P(sv):如果sv的值大于0,就给它减1;如果它的值为0,就挂起该进程的执行

S(sv):如果有其他进程因等待sv而被挂起,就让它恢复运行,如果没有进程因sv而挂起,就给他加1

共享内存

共享内存就是允许两个进程或多个进程共享一定的存储区,当一个进程改变了这个内存区域中的内容时,其他进程都会觉察到这个更改

特点:数据不需要在客户端和服务端之间来回复制,数据直接写到内存,减少了数次数据拷贝,是很快的一种IPC

缺点:共享内存没有任何的同步和互斥机制,需要使用信号量来实现对共享内存的存取和同步

套接字

套接字是一种允许两个不同进程进行通信的编程接口,通过套接字接口,可以使一台机器之间的进程可以相互通信,也可以使不同机器上的进程进行网络通信,套接字明确把客户端和服务端分开,实现了多个客户端连接到一个服务端

原理:服务器端应用程序调用socket创建一个套接字,它是系统分配给服务器进程的类似文件描述符的资源,不能与其他进程共享,服务器进程给这个套接字起一个名字,本地套接字的名字是Linux文件系统中的文件名,一般在/tmp或/usr/tmp中,网络套接字的名字与客户端连接的特定网络有关的服务标识符(端口号),系统调用bind给套接字命名后,服务器进程就开始等待客户端连接到命名套接字,系统调用一个listen创建一个队列,用于存放来自客户端的进入连接,服务器用accept来接收客户端的连接,当有客户端连接时,服务器进程会创建一个与原有命名不同的新的套接字,这个套接字只用于与这个特定的客户端进行通信,原有套接字继续处理来自其他客户端的连接。

套接字的域:

  1. AF_INET域:internet网络,是Novell NetWare网络协议的实现,也就是我们常说的IPV4
  2. AF_INET6域:internet网络,是Novell NetWare网络协议的实现,我们常说的IPV6
  3. AF_UNIX:unix文件系统域,底层协议的文件的输入和输出,地址就是文件名
  4. AF_ISO域:基于ISO标准协议的网络
  5. AF_XFS域:基于施乐(Xerox)的网络

​ AF_INET域中的类型

  1. 流套接字:通过TCP/IP连接实现,提供一个有序,可靠,双向字节流的连接,保证了发送的数据不会丢失,复制,或乱序到达
  2. 数据报套接字:通过UDP/IP实现,提供一个无序的,不可靠的服务,他不需要建立和维护连接,作为一个单独的网络消息被传输,可能会丢失,复制或乱序的到达,但开销很小,适用于单次查询,不保留连接信息

消息队列和管道的区别

  1. 管道是跟随进程的,消息队列是跟随内核的,也就是说进程结束后,管道就结束了,但消息队列还会存在
  2. 管道是文件,访问速度慢,消息队列是数据结构存放在内存,访问速度快
  3. 管道是数据流式存取,消息队列是数据块式存取

线程间通信

共享内存

线程之间共享程序的公共状态,通过读写这个公共状态来进行隐式通信,这会经历两个过程

  1. 线程A把本地内存A更新过的共享变量刷新到主内存中
  2. 线程B到主内存中读取线程A更新后的共享变量

消息传递

线程之间通过发送消息来显示的进行通信,比如wait()和notify()方法

线程的五种状态和生命周期

线程被创建并启动后,他既不是一启动就进入执行状态,也不是一直处于执行状态,在线程的生命周期中,他要经过新建(new),就绪(Runnable),运行(Running),阻塞(Blocked)和死亡(Dead)5种状态,在启动过后,他不可能会一直占用,所以状态会在运行和阻塞之间切换

  1. 新建状态(NEW):当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时仅有JVM分配内存,并初始化其成员变量的值
  2. 就绪状态(EUNNABLE):当线程对象调用了start()方法之后,该线程处于就绪状态,JAVA虚拟机会为其创建方法调用栈和程序计数器,等待调度运行
  3. 运行状态(RUNNING):如果处于就绪状态的线程获得了CPU,开始执行run方法的线程执行体,则该线程处于运行状态
  4. 阻塞状态(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu使用权,也就是让出了cpu timeslice,暂时停止运行,直到线程进入可可运行(Runtime)状态,才有机会获得cpu timeslice转到运行running状态
  5. 死亡状态(sleep/join):线程运行结束最后的状态

线程阻塞的三种情况

  1. 等待阻塞:运行的线程执行wait方法,JVM会把该线程放入等待队列中
  2. 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中
  3. 其他阻塞:运行的线程执行Thread.sleep()或者t.join()方法或者发出了I/O请求时,JVM会把该线程变成阻塞状态,当sleep或者join结束,I/O处理完成时,线程就会重新转入可运行状态

线程结束的三种方式

  1. run()或者call()方法执行完成,线程正常结束
  2. 线程抛出一个未捕获的Exception或Error
  3. 线程调用stop()方法来结束该线程-该方法通常容易导致死锁,不推荐使用

线程的上下文

线程的上下文是指某一个时间点寄存器和程序计数器的内容,上下文切换可以认为是内核(操作系统的核心)在CPU上对于进程(包括线程)进行切换,上下文切换过程中的信息是保存在进程控制块(PCB,process control block)的,PCB也被称作为切换桢

上下文的切换过程

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

上下文切换的原因

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

寄存器是CPU内部数量较少但是速度很快的内存,

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

线程调度器

线程调度器是一个操作系统服务,它负责为Runnable状态的线程分配CPU时间片,当一个线程被创建和启动后,它的执行便依赖于线程调度器,分配cpu时间可与基于线程的优先级或者线程等待的时间

线程调度类型

抢占式调度

抢占式调度指的是每条线程执行的时间,线程的切换都由系统控制,系统控制指的是在系统某种运行机制下,可能每条线程都分同样的执行时间片,也可能是

协同式调度

协同式调度指某一个线程执行完成之后主动通知系统切换到一个线程上执行,这种模式就像接力赛一样,一个接一个,线程的执行时间由线程本身控制,线程切换可以预知,不存在多线程同步的问题,缺点是如果一个线程编写有问题,一直在运行中,那么可能导致整个系统崩溃

调度算法

先进先出算法(FIFO)

按照任务进入队列的顺序,依次调用,执行完一个任务再执行下一个任务,只有当任务结束后才切换到下一个任务

优点:最小的任务切换开销(没有在任务执行中发生切换),最大的吞吐量(因为没任务切换开销),最朴实的公平性(先来先做)

缺点:平均响应时间高

适用场景:队列中任务耗时差不多的场景

最短耗时任务优先算法(SJF)

按照任务的耗时长短进行调度,优先调度耗时最短的任务,这个算法的前提是知道每个任务的耗时时间,需要注意的是耗时最短指的是剩余执行时间,解决了先进先出算法中短耗时任务等待长耗时任务的窘境

优点:平均响应时间低

缺点:耗时长的任务迟迟得不到调度,不公平,容易形成饥饿,频繁的任务切换,调度的额外开销大

时间片轮转算法(Round Robin)

给队列中的每个任务分配一个时间片,当时间片到了之后将此任务放到队列的尾部,切换到下一个任务执行,解决了SJF中长耗时任务饥饿的问题

优点:每个任务都能够得到公平的调度,耗时短的任务即使落在耗时长的任务后面,也能够较快的得到调度执行

缺点:任务切换开销大,需要多次切换任务上下文,时间片不好设置

适用场景:队列中耗时差不多的任务

JVM的线程调度实现

java使用的线程调度是抢占式调度,java中线程会按优先级分配cpu时间片,优先级越高越先执行,但是优先级高的线程并不能独自占用cpu时间片,只能是得到更多的cpu时间片,反之,优先级低的线程分到的执行时间少,但不会分配不到执行时间

线程调度器让线程让出cpu的情况

  1. 当前运行线程主动让出CPU,JVM暂时放弃CPU操作,例如调用yiled()方法
  2. 当前运行线程因为某种原因进入阻塞状态,例如阻塞在I/O上
  3. 当前运行线程结束,也就是运行完run方法里面的任务

守护线程和用户线程的区别

任何线程都可以设置为守护线程(Daemon)和用户线程(User),默认情况下新建的线程是用户线程,通过setDaemon(true)可以将线程设置为守护线程,这个函数必须在线程启动前进行调用,否则会报错,守护线程依赖于用户线程,当用户线程都退出了,守护线程也就退出了,典型的守护线程就是垃圾回收线程

线程安全

线程安全是某个函数,函数库在多线程的环境中被调用,能够正确的处理多个线程之间的共享变量,不会对共享资源产生冲突,不会影响程序的运行结果

线程不安全的原因

  • 线程切换带来的原子性问题
  • 缓存导致的可见性问题
  • 编译优化带来的有序性问题

解决方法

  • 原子性问题:JDK Atomic开头的原子类,synchronized,LOCK
  • 可见性问题: synchronized,volatile,LOCK
  • 有序性问题:Happens-Before规则

线程实践

创建线程的四种方式

  1. 继承Thread类
  2. 实现Runnable接口
  3. 通过Callable和Future创建线程
  4. 通过线程池创建

几种方式的区别

  • 实现Runnable,Callable接口的方式创建多线程

优势:线程类只是实现了Runnable接口或者Callable接口,还可以继承其他类,在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU,代码和数据分开,形成清晰的模型,较好的体现了面向对象的思想

劣势:编程稍微复杂,如果要访问当前线程,则必须Thread.currentThread()方法

  • 继承Thread类的方式创建多线程

优势:编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程

劣势:继承了Thread类,不能再继承其他类

  • Runnable和Callable的区别
  1. Callable重写的方法是call()方法,Runnable重写的方法是run()方法
  2. Callable的任务执行后可以返回值,Runnable的任务不能返回值
  3. Call方法可以抛出异常,run方法不能抛出异常
  4. 运行Callable任务可以拿到一个Future对象,表示异步任务的结果,提供了检查任务是否完成的方法,获取任务的结果,通过Future可以了解任务的执行情况,可以取消正在执行的任务

线程的基本方法

wait

使线程进入waiting状态,只有等待另外线程的通知或者被中断才会返回,调用会释放对象的锁,因此wait方法一般用在同步方法或者同步代码块中

sleep

使当前线程休眠,与wait方法不同的是sleep不会释放当前锁,会使线程进入timed-wating状态

yield

使当前线程从执行状态变为就绪状态,也就是当前线程让出本次cpu时间片,与其他线程一起重新竞争CPU时间片

interrupt

中断一个线程,本质是给这个线程发行一个终止通知信号,影响这个线程内部的一个中断标识位,这个线程本身并不会因此而改变状态,注意在线程处于timed-wating状态时调用interrupt会抛出InterruptedException,使线程提前结束timed-wating状态

中断状态是线程固有的一个标识位,通过isInterrupted来获取标识位的值,根据值然后调用thread.interruptd方法来安全的终止线程

join

等待其他线程终止,在当前线程中调用一个线程的join()方法,则当前线程转为阻塞状态,回到另一个线程结束,当前线程再由阻塞状态变为就绪状态,等待cpu分配,适用于主线程启动了子线程,需要用到子线程返回的结果,也就是主线程需要在子线程结束后再结束,这个时候就可以使用join方法

notify

线程唤醒,唤醒在此对象监视器上等待的单个线程,如果所有线程都在此对象上等待,则会选择唤醒其中的一个线程,选择是任意的

其他方法

  1. isAlive():判断一个线程是否存在

  2. activeCount():获取程序中活跃的线程数

  3. enumerate():枚举程序中的线程

  4. currentThread():得到当前线程

  5. isDaemon():判断当前线程是否为一个守护线程

  6. setDaemon():设置一个线程为守护线程

  7. setName():为一个线程设置一个名称

  8. setPriority():设置一个线程的优先级

  9. getPriority():获得一个线程的优先级

sleep()和wait()的区别

类不同:sleep方法属于Thread类中,wait方法属于Object类

释放锁:sleep不会释放锁,wait会释放锁,sleep不释放锁到指定时间就会自动唤醒,wait不会自动唤醒

应用场景不同:wait被用于通信,sleep被用于暂停执行

run()和start()的区别

start方法被用来启动新创建的线程,内部调用了run方法,这和直接调用run方法效果不一样,直接调用run方法的时候,只会是在原来的线程中调用,没有新的线程启动,start()方法会创建新的线程

notifyAll和notify的区别

notify只会唤醒一个线程,notiyfAll会唤醒所有线程

notify可能会导致死锁,而notifyAll则不会,notify是对notifyAll的一个优化

interrupted和isinterrupted的区别

interrupted会将中断状态清除,而isinterrupted不会,java多线程中的中断机制使用这个内部标识符来实现,当一个中断线程调用Thread.interrupt()来获取中断状态时,中断状态会被清除,并设置为true,而非静态方法调用isinterrupted用来查询其他线程的中断状态不会改变中断状态的标识,任何抛出InterruptedException异常的方法都会将中断状态清零

实现线程同步的方法

线程同步指线程之间的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒,线程的同步方法大体可以分为两类,用户模式和内核模式,内核模式指的是利用系统内核对象的单一性来进行同步,使用时需要切换内核态和用户态,例如事件,信号量,互斥量,用户模式不需要切换到内核态,例如原子操作(单一的一个全局变量),临界区

并发理论

三要素

  • 原子性: 一个或多个操作要么全部执行,要么全部不执行
  • 可见性: 一个线程对共享变量的修改,另一个线程可以能够立刻看见
  • 有序性: 程序执行的顺序按照代码的先后顺序执行

并行和并发

  • 并发:两个或多个(事件,任务,程序)在同一时刻发生,在一个处理器上交替执行这个任务,只是交替时间非常短,在宏观上是达到同时的现象
  • 并行:在同一时刻,有多条指令在多个处理器上同时执行,在宏观和微观上都是一起执行的

并发关键字

synchronized

synchronized可以把任意一个非NULL的对象当做锁,属于独占式的悲观锁,同时也是可重入锁

作用

synchronized关键字是用来控制线程同步的,在多线程的环境下,控制synchronized代码段不被多个线程同时执行

作用范围

  1. 方法:锁住的是对象的实例(this)
  2. 静态方法:锁住的是Class实例,又因为Class的相关数据存储在云数据空间,是全局共享的,所以静态方法相当类的一个全局锁,会锁住所有调用该类的方法
  3. 某一个对象实例:锁住的是所有以该对象为锁的代码块,他有多个队列,当有多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中

核心组件

  1. Wait Set:那些调用wait方法被阻塞的线程被放置这里面
  2. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中
  3. Entry List:存储在Contention List中有资格成为候选资源的线程
  4. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程就存在OnDeck中
  5. Owner:当前已经获取到锁资源的线程
  6. !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中的线程都处于阻塞状态,该阻塞是有操作系统来完成的(Linux 内核下采用pthread_mutex_lock内核函数实现的)
  6. Synchronized是非公平锁,Synchrionized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList中,这对于已经进入队列的线程是不公平的,还有一个不公平的就是在自旋获取锁的线程还可能会直接抢占OnDeck线程的资源
  7. 每个对象都有一个monitor对象,加锁就在竞争monitor对象,代码块加锁就在前后分别上monitorenter和monitorexit指令来实现,方法加锁是通过一个标识位来实现
  8. synchronized是一个重量级锁,需要调用操作系统相关的接口,性能是低效的,给线程加锁消耗的时间比有用操作消耗的时间更多
  9. 在java1.6 ,synchronized进行了很多的优化,有适应自旋,锁消除,锁粗化,轻量级锁及偏向锁等,效率有了本质上的提高,之后在java1.7和java1.8中均对该关键字的实现机制做了优化,引入了偏向锁和轻量级锁,都是在对象头中有标记位,不需要经过操作系统加锁
  10. 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,这种升级过程叫做锁膨胀
  11. JDK1.6中默认是开启偏向锁和轻量级锁,通过-XX:-UseBiasedLocking来禁用偏向锁

volatile

volatile相比synchronized更加轻量

volatile修饰的变量具有synchronized的可见性,但是不具备原子性,也就是线程能够自动发现volatile变量的最新值

volatile禁止了指令重排,在执行程序时,为了提升性能,处理器和编译器常常会对指令进行重排,指令重排虽然不会影响单线程的执行结果,但是会破坏多线程的执行语义,指令重排有两个条件,1在单线程环境下不能改变程序的运行结果,2存在数据依赖关系的情况不允许指令重排

适用场景:一个变量被多个线程共享,线程直接给这个变量赋值,例如状态标记量和单例模式的双检锁

ThreadLocal

ThreadLocal是一个本地线程副本变量工具类,主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发的场景下,可以实现无状态的调用,特别适用于各个线程依赖不同变量值完成操作的场景,是一种空间换时间的做法,在每个Thread里面维护了一个以开地址实现的ThreadLocal.ThreadLocalMap,把数据进行隔离,数据不共享,自然也就没有线程安全的问题了

基本方法

  1. get()获取ThreadLocal在当前线程中保存的变量副本
  2. set():设置当前线程中变量的副本值
  3. remove():移除当前线程中变量的副本
  4. initialValue:延迟加载方法

应用场景:

  1. 数据库连接,Session管理

final

特点

  1. 被final修饰的类不能被继承
  2. 被final修饰的方法不可以被重写
  3. 被final修饰的变量不可以被改变,如果修饰引用,那么表示引用不可变,引用指向的内容可变
  4. 被final修饰的方法,JVM会尝试将其内联,以提高运行效率
  5. 被final修饰的常量,在编译阶段会存入常量池

Synchronized和volatile的区别

volatile应用在多个线程对实例变量更改的场合,刷新主内存共享变量的值从而使得各个线程可以获得最新的值,线程读取变量的值需要从主内存中读取;synchronized是锁定当前变量,只有当前线程可以该变量,其他线程被阻塞住,synchronize会创建一个内存屏障,内存屏障保证了所有CPU操作结果都会直接刷到主内存中,从而保证了操作的内存可见性

volatile仅能使用在变量级别,synchronized则可以使用在变量,方法,和类级别

volatile不会造成线程的阻塞,synchronized会造成线程的阻塞

volatile只能保证变量的可见性不能保证原子性,synchronized保证了变量的可见性和原子性

volatile标记的变量不会编译器优化,可以禁止指令重排,synchronized标记的变量可以被编译器优化

锁理论

悲观锁

悲观锁是一种悲观思想,总是假设最坏的情况,即认为每次都是线程不安全的,每次读写都会进行加锁,synchronized就是悲观锁的实现

乐观锁

乐观锁是一种乐观思想,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只是在更新的时候判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制实现,适合于多读的应用场景,在数据库中write_condition机制就是乐观锁的实现,java中java.util.concurrent.atomic包下面的原子变量类也是使用了乐观锁的一种CAS实现方法

死锁

死锁的四个条件

  1. 互斥条件:一个资源每次只能被一个线程使用
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  3. 不剥夺条件:进程已经获得,在未使用完之前,不能强行剥夺
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

如何避免死锁

  • 指定获得锁的顺序,按照同一顺序获得访问资源,类似于串行执行

公平锁与非公平锁

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

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

在相同条件下,非公平锁的性能比公平锁高5-10倍,因为公平锁在多核的情况下需要维护一个队列,增加了开销

synchronized是非公平锁,ReentrantLock默认的lock()方法采用的也是非公平锁

共享锁和独享锁

java并发包提供的加锁模式分为独占锁和共享锁

独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock就是以独占方式实现的互斥锁,独占锁采用悲观锁的加锁策略,避免了读/读写冲突,如果某个只读线程获取了锁,则其他线程只能等待,这种情况其实不需要加锁,因为读操作并不会影响数据的一致性

共享锁允许多个线程同时获取锁,并发访问共享资源,加锁策略是乐观锁

可重入锁(递归锁)

可重入锁指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响,ReentrantLock和Synchronized都是可重入锁

自旋锁

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,获取锁的线程一直处于活跃状态

缺点:

  1. 如果某个线程持有锁的时间过长,就会导致其他等待获取锁的线程进入循环等待,消耗CPU,使用不当会造成CPU使用率极高
  2. 自旋锁是不公平的,无法满足等待时间最长的线程优先获取锁,不公平的锁就会存在线程饿死的问题

优点:

  1. 不会是线程状态发生切换,一直处于用户态,线程一直处于avtive的,不会使线程进入阻塞状态,减少了不必要的上下文切换

轻量级锁

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

轻量级是相对于使用操作系统互斥量来实现的传统锁而言,轻量锁不是用来代替重量级锁的,他的本意是在没有多线程竞争的前提下,减少传统重量级锁使用产生的性能消耗,适用于线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁升级为重量级锁

锁升级

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

重量级锁

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现,但是监视器锁本质是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换这就需要从用户态转换到内核态,这个成本非常高,状态之间的转换需要相对比较长的时间 ,这也就Synchronized效率慢的原因,这依赖于操作系统Mutex Lock所实现的锁称之为重量级锁,JDK1,6之后为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了轻量级锁和偏向锁

偏向锁

偏向锁的引入是为了在某个线程获得锁之后,消除这个线程的锁重入(CAS)的开销,看起来让这个线程得到了偏向,减少不必要的轻量级锁的执行路径,因为轻量级锁的获取和释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能

读写锁

读写锁分为读锁和写锁,读锁是无阻塞的,在没有写的情况下使用可以提高程序的效率,读锁和读锁不互斥,读锁和写锁互斥,写锁和写锁互斥

读锁适用于多个线程同时读,但不能同时写

写锁适用于只能一个线程写数据,且其他线程不能读取

分段锁

分段锁不是一种实际的锁,而是一种思想,ConcurrenHashMap就是使用的分段锁

Semaphore

Semaphore是一种基于计数的信号量,可以设定一个阈值,根据这个阈值,多个线程竞争获取许可信号,做完自己的申请后归还,超过阈值后,申请许可信号量的线程将会阻塞,Semaphore可以用来构建一些对象池,资源池之类的,也可以创建一个计数为1的Semaphore二元信号量,类似互斥锁的机制

CAS

CAS(Compare And Swap/Set)比较并交换,CAS是一种基于锁的操作,CAS操作包含三个参数(V,A,B),内存位置(V),预期原值(A)和新值(B),如果内存地址里面的值和A的值一样,那么就将内存里面的值更新成B

CAS操作采用的是乐观锁,,通过无限循环来获取锁,如果在第一轮循环中,A线程的值被B线程修改了,那么A线程需要自旋,到下次循环才有机会执行

缺点:

  1. 会导致ABA问题:线程1从内存位置V取出A,这个时候线程2也从内存位置V取出A,将A变成B,然后又变成A,这个时候线程1再进行CAS,发现还是A,然后线程1CAS操作成功,虽然线程1操作成功了,但是这中间经过了线程2,破坏了有序性,属于线程不安全,从jdk1.5开始引入了AtomicStampedReference
  2. 循环时间长开销大:资源竞争严重的情况,CAS自旋的概率会比较大,会浪费更多的CPU资源,效率低于synchronized
  3. 只能保证一个共享变量的原子操作:多个共享变量建议使用锁

AQS

AQS的全称是AbstractQueuedSynchronizer,这个类在java.util.concurrent.locks包下面,是一个用来构建锁和同步器的框架

核心思想:如果被请求的共享资源空闲,则将当前请求资源的线程设置成有效的工作线程,并且将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列来实现的,即将暂时获取不到的锁加入到队列中

CLH队列是一个虚拟的双向队列,虚拟的双向队列即是一个不存在的队列实例

基于AQS的实现:ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作,AQS使用CAS对该同步状态进行原子操作实现对其值的修改

private volatile int state;//共享变量,使用volatile修饰保证线程的可见性

//获取同步状态的当前值
protected final int getState(){
return state;
}
//设置同步状态的值
protected final void setState(int newState){
  state = newState
}
//原子操作(CAS操作),将同步状态值设置为给定值
protected final boolean compareAndSetState(int expect,int update){
 return unsafe.compareAndSwapInt(this,stateOffset,expect,update);
}

AQS定义了两种对资源的共享方式

  1. Exclusive(独占):只有一个线程能执行,如ReentrantLock,这里面又可分为公平锁和非公平锁
  2. Share(共享):多个线程可同时执行

不同的自定义同步器争用共享资源的方式也不同,自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败时入队和唤醒出队等),AQS在顶层已经实现了

AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法

isHeldExclusively()//该线程是否正在独占资源,只有用到condition才需要去实现它
tryAcquire(int) //独占方式,尝试获取资源,成功返回true,失败返回false
tryRelease(int) //独占方式,尝试释放资源,成功返回true,失败返回false
tryAcquireShared(int) //共享方式,尝试获取资源,负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源;
tryReleaseShared(int) //共享方式,尝试释放资源,成功则返回true,失败返回false

默认情况下每个方法都抛出UnsupportedIOperationException.这些方法的实现必须都是内部线程安全的。并且通常应该简短而不是阻塞,AQS类中的其他方法都是final,所以无法被其他类使用,只有这几个方法可以被其他类使用

一般来说,自定义同步器要么是独占方式,要么是共享方式,只需要实现tryAcquire-tryRelease或者tryAcquireShared-tryReleaseShared,但是AQS也支持同时实现两种方式,比如ReentrantReadWriteLock

锁实践

synchronized

三种使用方式

  1. 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  2. 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员
  3. 修饰代码块:指定加锁对象,给它加锁,进入同步代码库前要获得给定对象的锁

总结,synchronized关键字加到static静态方法和代码块上都是给Class类上锁。加到实例方法就是给对象实例上加锁,尽量不要使用到String类型上,因为JVM中字符串常量池具有缓存功能

底层实现原理

public class SynchronizedDemo {
    public void method(){
        synchronized (this){
            System.out.println("synchronized---code");
        }
    }
}

使用javap反编译后 javap -c -v SynchronizedDemo

image-20230308163553534

在执行方法之前之后都有一个monitorenter和monitorexit字符,前面的monitorenter就是获取锁,执行完代码后释放锁,执行monitorexit,第二个monitorexit是为了防止在同步代码块中因异常退出而没有释放锁的情况下,第二遍释放,避免死锁的情况

可重入的原理

重入的原理是一个线程在获取到该锁之后,该线程可以继续获得锁,底层原理维护了一个计数器,当线程获得该锁时,计数器加1,再次获得该锁时继续加1,释放锁时,计数器减1,当计数器值为0时,表明该锁未被任何线程所持有,其他线程可以竞争获取锁

锁升级的原理

在锁对象的对象头里面有一个threadId字段,在第一次访问的时候threadId为空,jvm让其持有偏向锁,并将threadid设置为线程id,再次进入的时候会先判断threadid是否与其线程id一致,如果一致则可以继续使用该对象,如果不一致则升级为了轻量级锁,通过自旋循环一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁再升级为重量级锁,从而降低锁带来的性能消耗

Lock

Lock可以说是Synchronized的扩展版,Lock提供了无条件的,可轮训的(tryLock方法),定时的(tryLock(long timeout,TimeUnit unit)),可中断的(lockInterruptitibly),可多条件的(newCondition方法)锁操作,另外Lock的实现类基本都支持非公平锁和公平锁,synchronized只支持非公平锁

优势:

  1. 可以使锁更公平
  2. 可以使线程在等待锁的时候响应中断
  3. 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
  4. 可以在不同范围,以不同顺序获取和释放锁

主要方法

  1. void lock();如果锁处于空闲状态,则当前线程获取到锁,如果锁已经被其他线程持有,则将当前线程阻塞,直到获取到锁
  2. boolean tryLock();如果锁处于可用状态,则当前线程获取锁并且返回true,如果锁是不可用状态,则不会将当前线程阻塞
  3. void unlock();释放当前线程所持有的锁,这个方法只能由持有者释放,如果当前线程不说持有者,也就是当前线程不持有锁,那么将会抛出异常
  4. Condition newCondition();条件对象,获取等待通知组件,该组件和当前锁绑定,当前线程只有获取到了锁,才能调用该组件的await方法,await调用后将释放锁
  5. getHoldCound():查询当前线程保存此锁的次数,也就是执行lock方法的次数
  6. getQueueLength():返回正在等待的获取此锁的线程估计数,比如如果是20个线程,获取到的可能是18个
  7. getWaitQueueLength(Condition condition):返回等待与此锁相关的给定条件的线程估计数,比如10个线程都用condition对象调用了await方法,那么此方法返回10
  8. hasWaiters(Condition condition):查询是否有线程等待与此锁相关的给定条件
  9. hasQueueThreads(Thread thread):查询给定线程是否在等待获取锁
  10. hasQueuedThreads():是否有线程在等待此锁
  11. isFair():该锁是否是公平锁
  12. isHeldByCurrentThread():当前线程是否保存锁锁定,线程执行lock方法的前后分别是false和true
  13. isLock:此锁是否有任意线程占用
  14. lockInterruptibly():如果当前线程未被中断,获取锁
  15. tryLock():尝试获取锁,仅在调用时锁未被线程占用,获取锁
  16. tryLock(long timeout,TimeUnit unit):如果锁在给定等待时间内没有被另一个线程保持,则获取该锁

ReentrantLock

ReentrantLock继承接口Lock并实现了定义的方法,是一种可重入锁,除了能完成Synchronized所能完成的所有功能外,还提供了诸如可响应的中断锁,可轮询锁请求,定时锁等避免死锁的方法

使用ReentrantLock必须在finally中进行解锁操作,避免程序出现异常而无法正常解锁的情况

Synchronized和Lock的区别

synchronized是悲观锁,属于抢占式,会引起其他线程阻塞

Synchronized和ReentrantLock的区别

两个都是可重入锁

Synchronized是和if,else,for一样的关键字,ReentrantLock是类

  1. ReentrantLock使用相比Synchronized更加灵活
  2. ReentrantLock必须手动获取和释放锁,Synchronized是自动,由jvm控制
  3. ReentrantLock只适用于代码块,Synchronized可以适用于类,方法,变量等
  4. ReentrantLock底层是使用Unsafe的park方法加锁,synchronized操作是对象头的monitorenter加锁

ReentrantLock相比synchronized的优势是可中断,公平锁,多个锁

Condition类和Object类锁的区别

  1. Condition类的await方法和Object类的await方法等效
  2. Condition类的signal方法和Object类的notify方法等效
  3. Condition类的signalAll方法和Object类的notifyAll方法等效
  4. ReentranLock类可以唤醒指定条件的的线程,而Object的唤醒是随机的

tryLock和Lock和lockInterruptibly的区别

  1. tryLock能获取锁就返回true,不能就返回false,tryLock(long timeout,TimeUnit unit)增加时间限制,超过该时间还没获取到锁,则返回false
  2. lock能获取到锁就返回true,不能的话就会一直等待获取锁
  3. lock和lockInterruptibly,如果两个线程分别执行这两个方法的时候被中断,lock不会抛出异常,lockInterruptibly会抛出异常

锁优化

减少锁持有时间

只用在有线程安全要求的程序上加锁

减小锁粒度

将大对象(这个对象可能会被很多线程访问),拆成小对象,增加并行度,ConcurrentHashMap就是其中的一个实现

降低锁竞争

使用偏向锁,轻量级锁,降低锁竞争

锁分离

根据功能将锁分离开,一般分为读锁和写锁,做到读读不互斥,读写互斥,写写互斥,保证了线程安全,又提高了性能

锁粗化

正常来说是为了保证线程间有效并发,会要求锁的持有时间尽量短,但是如果时间太短,多个线程对一个锁不停的请求,同步,释放,这其中对锁的开销也会浪费系统资源

锁消除

对不需要共享的资源中取消加锁

容器

容器类存放于Java.util包中,主要有3种:Set(集),list(列表,包含Queue)和Map(映射)

  1. Collection:Collection是集合List,Set,Queue的最基本的接口
  2. Iterator:迭代器,可以通过迭代器遍历集合中的数据
  3. Map:映射表的基础接口

Set

  1. HashSet:无序,唯一,基于HashMap实现,底层采用HashMap来保存元素
  2. LinkedHashSet:继承于HashSet,内部是通过LinkedHashMap来实现
  3. TreeSet:有序,唯一,红黑树的数据结构
  4. HashMap:JDK1.8之前由数组+链表,链表是为了解决hash冲突而存在,JDK1.8之后由数组+链表+红黑树组成,当链表长度大于阈值8时,将链表转换为红黑树,减少搜索时间
  5. LinkedHashMap:继承自HashMap,底层是基于数组和链表或红黑树组成,增加了一个双向链表,使数据可以保持键值对的插入顺序
  6. HashTable:数组+链表组成,数组是HashMap的主体,链表主要是为了解决哈希冲突而存在
  7. TreeMap:红黑树

List

List是有序的Collection,实现有ArrayList,Vector,LinkedList

ArrayList

底层通过数组实现,允许对元素进行快速随机访问,缺点是每个元素之间不能有间隔,当容量不够需要增加容量时,需要将原来的数据复制到新的存储中间中,当进行插入或者删除时,需要对数组进行复制,移动,代价比较高

适合随机查寻和遍历,不适合插入和删除,打印时使用Arrays.toString()输出每个元素

Vector

底层是通过数组实现,是线程安全的,避免了多线程同时写而引起的不一致性,性能比ArrayList慢

LinkedList

采用双向链表结构存储数据,很适合数据的动态插入和删除,随机访问和遍历速度比较慢,提供了专门操作表头和表尾的元素

Set

set有独一无二的性质,用于存储无序(存入和取出)元素,值不能重复,数据是否重复的本质是比较对象的HashCode值,所以如果想要让两个对象相同,就必须覆盖Object的hashCode和equals方法,实现有HashSet,TreeSet,LinkHashSet

HashSet

内部采用HashMap实现,不允许重复的Key,只允许一存储一个null对象,判断key是否重复通过HashCode值来确定

TreeSet

使用二叉树的结构存储数据,二叉树是有序的,排序时需要实现Comparable接口,重写comoare函数,排序时该函数返回负整数,零,正整数分别对应小于,等于,大于

LinkhashSet

继承于HashSet,实现了LinkedHashSet,底层采用LinkedHashMap来保存元素

Map

HashMap

不是一个线程安全的容器,默认容量是16,根据键的hashCode存储数据,大多数情况可以根据hash函数一次性获取到数据,因此具有很快的查询速度,键只允许一个null,值可以有多个null

JDK1.7采用数组+链表的方式存储,每次扩容都是2^n,扩容后是原来的两倍,负载因子是0.75,扩容的阈值是当前数组容量*负载因子

JDK1.8采用数组+链表+红黑树方式存储,相比JDK1.7多了一个红黑树,当链表的元素超过了8个以后会将链表转换成红黑树

ConcurrentHashMap

ConcurrentHashMap是一个线程安全的容器,底层是一个Segment数组,默认长度是16,所以并发数是16,通过继承ReentrantLock进行加锁,每次加锁的时候只锁住数组中的一个Segment,也就是分段锁的思想,只要保证了操作的Segment线程安全,也就实现了全局的线程安全

HashTable

HashTable功能和HashMap相识,不同的是他属于线程安全的,继承自Dictionary,在性能上不如concurrentHashMap,使用场景较少,因为线程安全的时候使用concurrentHashMap,不需要保证线程安全的时候使用HashMap

TreeMap

TreeMap实现了SortedMap接口,底层数据结构是红黑树,保存的时候根据键值升序排序,适用于在遍历的时候需要得到的记录是排序后的,注意在使用的时候,key必须实现Comparable接口,否则就会抛出运行时异常java.lang.ClassCastException

LinkedHashMap

LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用iterator遍历的时候,得到的记录肯定是先插入的,可以在构造时带参数,控制访问次序排序

使用场景

  1. 不需要线程安全,对性能要求高使用HashMap
  2. 需要线程安全,使用ConcurrentHashMap
  3. 需要排序,使用treeMap或者LinkedHashMap

Queue

常用队列

  1. ArrayBlockingQueue:基于数组的并发有界阻塞队列
  2. ArrayDeque:数组双端队列
  3. ConcurrentLinkedQueue:基于链表的并发队列
  4. DelayQueue:使用优先级排序的延期无界阻塞队列
  5. LinkedBolckingQueue:基于链表的FIFO有界阻塞队列
  6. LinkedBolckingDeque:基于链表的FIFO双端阻塞队列
  7. LinkedtransferQueue:由链表组成的无界阻塞队列
  8. PriorityQueue:优先级队列
  9. PriorityBlockingQueue:带优先级排序的无界阻塞队列
  10. SynchronousQueue:并发的同步阻塞队列,不存储元素

主要方法

  1. add():添加元素到队列里,成功返回true,如果容量满了会抛出IllegalStateException
  2. addFirst():插入元素到队列头部,失败抛出异常
  3. addLast():插入元素到队列尾部,失败抛出异常
  4. offer():添加元素到队列里,成功返回true,失败返回false或抛出异常
  5. offerFirst():添加元素到队列头部,成功返回true,失败返回false或者抛出异常
  6. offerLast():添加元素到队列尾部,成功返回true,失败返回false或者抛出异常
  7. remove():取出并移除头部元素,成功返回true,失败返回false,空队列抛出异常
  8. removeFirst():取出并移除头部元素,空队列抛出异常
  9. removeLast():取出并移除尾部元素,空队列抛出异常
  10. poll():取出并删除队列头部元素,如果队列为空返回null
  11. pollFirst():取出并移除头部元素,如果队列 为空返回null
  12. pollLast():取出并删除尾部元素,如果队列为空返回null
  13. getFirst():取出但不删除头部元素,如果队列为空抛出异常
  14. getLast():取出但不删除尾部元素,如果队列为空抛出异常
  15. peek():取出但不移除头部元素,空队列返回null
  16. peekFirst():取出但不删除头部元素,空队列返回null
  17. peekLast():取出但不删除尾部元素,空队列返回null
  18. put():添加元素到队列里,成功返回true,如果容量满了会阻塞直到容量不满
  19. take():删除队列头部元素,如果队列为空,一直阻塞到队列有元素并删除
  20. removeFirstOccurrence(Object o):删除队列中第一次出现的指定元素,如果不存在则队列不变,删除成功返回true
  21. removeLastOccurrence(Object o):删除队列中最后一次出现的指定元素,如果不存在则队列不变,删除成功返回true

集合和数组的区别

  1. 数组是固定长度,集合是可变长度
  2. 数组可以存储基本数据类型和引用数据类型;集合只能存储引用数据类型
  3. 数据存储的元素必须是同一个类型,集合存储的数据可以是不同数据类型

线程池理论

原理

利用池化思想,将线程管理起来,使用的时候不需要再创建和销毁,即用即拿提高了效率,减少了线程的开销,方便了管理

优点

  1. 降低线程的创建和销毁开销:通过重复利用已创建的线程来减低开销
  2. 提高响应速度:需要使用线程的时候,直接从池里面取,不需要等线程创建,相当于提前初始化了
  3. 提高线程的可管理性:因为多个线程在一个池里面,所以可以进行统一分配管理,方便调优和监控

核心参数

  1. corePoolSize:线程池核心线程数量
  2. maximumPoolSize:线程池最大线程数量
  3. keepAliverTime:当活跃线程数大于核心线程数时,多余线程的最大存活时间
  4. TimeUnit:存活的时间单位
  5. workQueue:存储线程池的任务队列,用来构建线程池里面的worker线程
  6. threadFactory:创建线程的工厂,可以用来设置线程名,是否为守护线程等
  7. handler:超出线程数范围或队列容量的拒绝策略

常用WorkQueue的队列

  1. ArrayBlockingQueue:基于数组的有界阻塞队列,按照FIFO先进先出的原则对元素进行排序
  2. LinkedBlockingQueue:基于链表的阻塞队列,按照FIFO先进先出的原则排序元素,吞吐量一般高于ArrayBlockingQueue,使用这个队列时maximiumPoolSize相当于无效
  3. SynchronousQueue:一个不缓存任务的阻塞队列,也就是说新任务进来的时候会被直接调用,如果没有可用线程则会创建新线程,直到最大数
  4. PriorityBlockingQueue:一个具有优先级的无界阻塞队列,基于二叉堆实现,优先级通过Comparator参数实现
  5. DelayQueue:无界的阻塞队列,生产者不会被阻塞,消费者会被阻塞

工作过程

  1. 刚创建时没有一个线程,队列有任务也不会执行

  2. 当调用execute时会根据当前线程数做出如下判断

    1. 当线程池中的线程数量小于corePoolSize时则创建线程,并处理请求

    2. 当线程池中的线程数量大于等于corePoolSize时,则把请求放入workQueue中,随着线程池中核心线程们不断执行任务,只要线程池中有空闲的核心线程就从workQueue中获取任务并执行

    3. 当workQueue已存满时则新建非核心线程入池,并处理请求直到线程数量达到MaximumPoolSize(最大线程数量)

    4. 当线程池中线程数大于maximumPoolSize则使用相应的拒绝策略进行拒绝处理

  3. 当一个线程完成任务时,从队列中取出下一个任务继续执行

  4. 当一个线程无事可做,超过一定空闲时间时,线程池会做出判断,如果当前线程数大于核心线程数,那么这个线程就会被销毁,所以当所有任务完成后,线程池最终会收缩到核心线程数的大小

总结:执行任务时检查线程池中的线程数量,小于核心数则新建一个线程执行任务,大于等于核心数则放入任务队列,大于核心数且小于最大线程数则创建新线程,当线程数大于核心线程数,且空闲时间超过了keepalive时则会销毁线程

拒绝策略

ThreadPoolExecutor.AbortPolicy

线程池默认的拒绝策略,当线程池中数量达到最大线程数时抛出java.util.concurrent.RejectedExcutionException异常,任务不会被执行

ThreadPoolExecutor.DiscardPolicy

默默丢弃不能执行的新加任务,不会抛出异常

ThreadPoolExcutor.CallerRunsPolicy

重试添加当前的任务,会自动重复调用execute()

ThreadPoolExecutor.DiscardOldestPolicy

抛弃线程池中工作队列头部的任务,也就是等待得最久的任务,并执行新传入的任务

线程池实践

创建方式

  1. ThreadPoolExecutor:手动创建线程池
  2. Executors:自动创建线程池的工具类

自带的四种线程池

  1. newSingleThreadExecutor:只有一个线程的线程池,在线程死后或异常时会重新启动一个线程来替代原来的线程继续执行下去
  2. newFixedThreadPool:线程数固定大小的线程池,每次提交任务就创建一个新线程,直到线程达到线程池的最大大小
  3. newCachedThreadPool:缓冲线程池,不会对线程池的大小做限制,线程池大小依赖于JVM能够创建的线程数大小,线程空闲时间超过60秒就会被销毁,适用于执行很多短期异步任务
  4. newScheduledThreadPool:大小无限的线程池,适用于定时和周期性执行任务的场景

自带线程池的注意点

Executors.newFixedThreadPool(10)

底层是通过new ThreadPoolExecutor(10,10,0L,TimeUnit.MILLSECONDS,new LinkedBlockingQueue())创建,初始化一个指定线程数的线程池,其中核心线程数和最大线程数相同,使用LinkedBlockingQueue作为阻塞队列,当线程没有可执行任务时不会释放线程,由于使用LinkedBlockingQueue的特性,这个队列是无界,若消费不过来,会导致内存被任务队列占满,最终OOM

Executors.newCachedThreadPool()

缓存线程池,底层通过new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L,TimeUnit.SECONDS,new SynchronousQueue())创建缓冲线程池,因为线程池的最大值是Integer.MAX_VALUE,所以当并发数很大,线程池来不及回收时,会导致严重的性能问题

Executors.newSingleThreadExecutor()

队列使用的LinkedBlockingQueue无界队列,可以无限添加任务,直到内存溢出

线程池中submit()和execute()的区别

submit可以返回持有计算结果的Future对象,execute返回类型是void

如何合理设置线程池的大小

线程池大小要看执行什么类型的任务,一般可分为CPU密集型,IO密集型

CPU密集型应该使用较小的线程池,一般为CPU核心数+1

IO密集型两种方式 1:使用较大的线程池,一般为CPU核心数2,2:(线程等待时间与线程CPU时间之比+1)CPU核心数

并发工具

CountDownLatch

CountDownLatch是基于AQS共享模式的实现,适用于一个或多个线程等待某个条件,期间阻塞,直到所有线程都符合,然后继续执行的场景

构造时传入一个int参数作为计数器,主要方法是countDown和await,每调用一次countDown()方法计数器减1,await方法会阻塞,直到计数器为0

CountDownLatch不能够重用,计数器只能做减法,如果需要重用考虑使用CyclicBarrier或者重新创建CountDownLatch

CyclicBarrier

CyclicBarrier中文叫栅栏,也可以叫同步屏障,让一组线程都到达栅栏之前阻塞,当最后一个线程到达栅栏后放行,好比一扇门,默认是关闭状态,阻塞线程的运行,直到所有线程都就位时,门才打开,让所有线程一起通过,最后还可以关闭,继续下一轮

构造时传入一个int参数作为需要拦截的线程数,每当一个线程调用await方法就会告诉CyclicBarrier已经有一个线程到达栅栏

Semaphore

许可证集合,用于限制可以访问某些资源的线程数目,这里的限制指的是资源的互斥而不是同步,只能保证在同一时刻资源是互斥的,但不是同步的,这点和锁不一样

常用方法

  1. acquire(int permits):获取给定数目的许可,在获得许可之前将被阻塞
  2. release(int permits):释放给定数量的许可
  3. availablePermits(),获取当前剩余可用的许可数
  4. reducePermits(int reduction):删除reduction数量的许可
  5. hasQueuedThread:查询是否有线程等待获取许可
  6. getQueueLength():获取正在等待的线程估计数量,注意是估计值
  7. tryAcquire(int permits,long timeout,TimeUnit unit):尝试在指定时间内获取许可
posted @ 2023-03-09 19:16  无回实验室  阅读(559)  评论(0编辑  收藏  举报