面试题-并发编程
多线程
进程
系统进行资源分配和调度的基本单位,进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。进程是一个实体。每一个进程都有它自己的地址空间,,进程是一个“执行中的程序”。
线程
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),
线程与进程的区别
共同点
都能提高程序的并发度,提高程序的运行效率和响应时间,使用上各自都有优缺点,线程的开销较小,但不利于资源的的管理和保护,而进程相反。
不同点
多进程中每个进程有自己的地址空间,线程则共享地址空间。以下的不同点,都是通过这个产生的
- 进程是资源分配的最小单位,线程是CPU调度的最小单位
- 同一进程中的多个线程共享进程的同一资源
- 进程间是相互独立的,同一进程的线程间共享。
线程的几种状态
- 新建状态:构造方法,new一个新线程时,该线程是新建状态
- 就绪状态:新建线程后,调用start方法启动线程,线程进入就绪状态,由于还没有分配CPU ,线程进入线程队列中。一旦获取到CPU则进入执行状态,调用自己的run方法。
- 运行状态:执行run方法,直到调用其他方法或发生阻塞时停止。
- 阻塞状态:某些特殊的情况下,线程可以被挂起,则线程进入阻塞状态,当执行 sleep()、suspend()、wait()方法时,线程进入阻塞状态。当引起阻塞状态的原因消失时,线程转为就绪状态,当再次分配CPU时,则从之前停止的地方继续执行。
- 死亡状态:线程调用stop()、destory()或run()方法执行结束后,线程处于死亡状态,处于死亡状态的线程不再具有继续运行的能力。
线程的调度方法
(1)wait():当线程调用了这个方法时,线程被阻塞挂起,。直到调用了notify
方法或notifyAll
唤醒方法。
(2)notify():当调用notify
方法后,会唤醒一个在这个锁资源上调用wait
方法后被挂起的线程,
(3)notifyAll():notify()是唤醒一个在等待的线程,而notifyAll()方法则会唤醒所有在该锁资源上由于调用wait()方法而被挂起的线程。
(4)sleep():当调用这个方法后,线程暂时让出指定时间的CPU执行权,但是锁还是持有的状态,当到了sleep的指定时间后,接着获取资源执行。
(5)interrupt():中断线程的方法,线程会时不时的检测这个中断标志位为true,以判断线程是否应该被中断。
(6)join():在当前线程执行另一个线程,当前线程会阻塞,等待插入的线程执行完毕之后,才会从阻塞状态变为就绪状态。(类似于插队)
(7)yield():可以让当前正在运行的线程暂停,不会让当前的线程阻塞,而是进入到就绪状态,让同优先级或者更高的优先级获取CPU。
线程池的好处
线程池的好处:
- 降低资源消耗:重复利用已经创建的线程,降低线程创建和销毁造成的消耗。
- 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
线程是一种很宝贵的资源,创建和销毁线程需要付出很多代价,而使用线程池则避免了频繁的创建和销毁,因此我们有必要对线程池的原理进行了解。
线程池工作原理主要有三个方面:线程状态,线程池的重要属性和线程池的工作流程。
线程池的状态
- 运行状态(running):此状态下,线程可以接受新的任务,也可以处理阻塞队列中的任务,执行shutdown则进入待关闭状态,执行shutdownNow()方法可进入停止状态。
- 待关闭状态(shutdown):此状态下,线程池不再接受新的任务,继续处理阻塞队列中的任务,当阻塞队列中的任务执行完成后,进入整理状态。
- 停止状态(stop):此状态下,线程池不接受新的任务,也不处理阻塞队列中的任务,反而会尝试结束执行中的任务,当工作线程数为0时,进入整理状态。
- 整理状态(tidying):此状态下,所有的工作都执行完毕,且没有工作线程,执行terminated方法进入终止状态。
- 终止状态(terminated):此状态下,线程池完全终止,完成了所有资源的释放。
线程池有几种
六种:
(1)FixedThreadPool:由于它的核心线程数和最大线程数是一样的,所以被称为固定线程数的线程池,就算是任务数超过了线程数,则线程池也不会再创建更多的线程来处理任务,而是把超出线程数的任务加入到任务队列中
1 |
|
(2)CachedThreadPool:被称作为可缓存线程池,它的特点在于线程数可以无限增加,当提交一个任务,没有空闲线程,则新建线程去执行任务。并且对闲置的线程还可以进行线程的垃圾回收。当然它也有一个任务队列,(SynchronousQueue)队列的容量为0,不存储任何任务,只负责中转和传递,因此效率很高。
1 |
|
(3)ScheduledThreadPool:定期或周期性的执行任务,实现这种功能主要有三种方法。
1 |
|
1 |
|
(4)SingleThreadExecutor:使用唯一的线程去执行任务,和FixedThreadPool原理一样,只不过它只有一个线程,这种最适用于所有任务都需要按照被提交的顺序依次执行的场景,前面几种线程池,都不能保证执行的顺序,因为是多线程并发执行的
1 |
|
(5)SingleThreadScheduledExecutor:与SchedulesTreadPool线程池非常相似,只不过它只有一个线程。
1 |
|
(6)ForkJoinPool:JDK7新加入的线程池,主要用法和之前的线程池是相同的,也是把任务交给线程池去执行,线程池中也有任务队列来存放任务。但是 ForkJoinPool 线程池和之前的线程池有两点非常大的不同之处。(ForkJoinPool 非常适合用于递归的场景,例如树的遍历、最优路径搜索等场景。)
适合执行产生子任务的任务:有一个 Task,这个 Task 可以产生三个子任务,三个子任务并行执行完毕后将结果汇总给 Result,这样就可以利用 CPU 的多核优势,并行计算。
第二点不同之处在于内部结构,之前的线程池所有的线程共用一个队列,但 ForkJoinPool 线程池中每个线程都有自己独立的任务队列。这时一旦线程中的任务被 Fork 分裂了,分裂出来的子任务放入线程自己的 deque 里,而不是放入公共的任务队列中。
线程池推荐创建方式
1 |
|
线程池参数和流程?
参数名 | 参数含义 |
---|---|
corePoolSize | 核心线程数 |
maxinumPoolSize | 最大线程数 |
keepAliveTime | 空闲线程存活时间 |
unit | 存活时间的单位 |
workQueue | 存放线程任务队列 |
threadFactory | 线程工厂,创建新线程 |
handler | 线程池拒绝处理后的任务 |
(1)corePoolSize:创建工作的线程数,这些线程创建后不会消失,是一种常驻线程。
(2)maxinumPoolSize:表示最大允许被创建的线程数,当核心线程全部用完时,还无法达到要求,此时会创建新的线程,但线程池内线程总数不会超过最大线程数。
(3)keepAliveTime:超出核心线程之外的空闲线程存活时间,也就是核心线程不会消除,可以通过 setKeepAliveTime
来设置空闲时间
(4) workQueue:用来存放待执行的任务,如果核心线程全部用完后,,还有任务进来则加入到任务队列中,如果任务队列放满,还有任务队列加入,,则创建新的线程。
(5)threadFactory:用来生产临时线程执行任务。
(6)handler:任务拒绝策略,第一种:当线程池关闭时,再继续提交任务会遭到拒绝。第二种:达到最大的线程数时,没有能力继续处理新提交的任务,则拒绝。(RejectedExecutionHandler类型的变量)
用户可以自行指定拒绝的策略,ThreadPoolExecutor提供了四种策略:
- ThreadPoolExecutor.AbortPolicy(默认的):丢弃任务,抛出异常。
- ThreadPoolExecutor.DiscardPolicy:直接丢弃该任务,不抛出异常
- ThreadPoolExecutor.CallerRunsPolicy:使用调用者线程执行该任务
- ThreadPoolExecutor.DiscardOldestPolicy:丢弃任务队列中的最老的一个任务,然后提交该任务
线程池流程
阻塞队列
- 有界队列:当阻塞队列中装满了等待执行的任务,这时再有新任务提交时,线程池就需要创建新的临时线程来处理,相当于增派人手来处理任务。
- 无界队列:当核心线程都在忙时,所有新提交的任务都会被存放在该无界队列中,这时最大线程数将变得没有意义,因为阻塞队列不会存在被装满的情况。
获取任务并执行
获取任务的过程则需要考虑当前工作线程的个数:
1.如果工作线程数大于核心线程数,那么就需要通过poll(keepAliveTime, timeUnit)来获取,因为这时需要对闲置线程进行超时回收。
2.如果工作线程数小于等于核心线程数,那么就可以通过take()来获取了。因为这时所有的线程都是核心线程,不需要进行回收,前提是没有设置allowCoreThreadTimeOut(允许核心线程超时回收)为true。#
如何合理的配置线程池
(1)核心线程数的设置主要取决于是IO密集型还是CPU密集型
IO密集型:指的CPU的性能要比系统硬盘和内存性能好的多,执行任务需要大量的io,可能会出现大量的阻塞,所以IO密集型中要使用大量的线程处理任务,因此,线程池核心数为=CPU核心数*2。
CPU密集型:指的是系统硬盘和内存的性能要比CPU好的多,主要是进行大量的计算,这种场景的线程池核心数为=CPU核心数+1。
(2)使用有界队列,防止OOM
进程和线程之间的通信方式
实际上只有进程间需要通信,同一进程的线程共享地址空间,没有通信的必要,但要做好同步/互斥,保护共享的全局变量。而进程间通信无论是信号,管道pipe还是共享内存都是由操作系统保证的,是系统调用。
进程通信
管道
管道传输的数据是单向的,而且只能在父子进程关系中使用
有名管道
数据的传输也是单向的,但是它允许非父子进程之间的通信
消息队列
A进程给B进程发送消息,A进程把数据放在对应的消息队列后就可以正常的返回,B进程需要的时候再进行读取数据。消息队列是保存在内存中的消息链表,在发送数据的时候,会分成一个独立的数据单元,即就是数据块
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
共享内存
消息队列中的读取和写入的过程,都会有用户态和内核态之间的消息拷贝过程。共享内存的机制就是拿出一块虚拟化的地址空间来,映射到相同的物理内存中。这样这个进程写入东西,另外一个进程就可以看到了,大大提高了进程间通信的速度。
信号量
如果用了共享内存的通信方式,会产生新的问题,就是多个线程同时修改同一个共享内存,为了防止多个进程访问资源造成数据错乱,需要共享资源在任意时刻只能被一个线程访问,因此信号量实现了这一保护机制。
信号
用于通知接收进程某个事件已经发生
任何时候给某一进程发送信息,一旦信号产生,就会有这几种的方式:
1、执行默认的操作
2、扑捉信号
3、忽略信号套接字
管道、消息队列、共享内存、信号量和信号是在同一台主机上进行通信的,若想跨网络与不同的主机之间上的进程之间通信,需要用socket套接字啦
线程通信
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
等待通知机制
两个线程通过对同一对象调用等待 wait() 和通知 notify() 方法来进行通讯。
wait:是指目前已经获得锁的线程,让出同步锁,让其他线程获取,也就是释放了锁。
notify:获取到锁的线程唤醒刚才调用wait的线程可以去参与竞争锁了,但当前的线程不会释放锁
join()方法
在 join 线程完成后会调用 notifyAll() 方法,是在 JVM 实现中调用,所以这里看不出来。
volatile共享内存
多个线程同时监听一个变量,当该变量发生变化的时候,线程能够感知并执行相应的业务。
管道通信
并发工具
- CountDownLatch 并发工具
jdk1.5之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了并发编程代码的书写,CountDownLatch 基于AQS框架,相当于也是维护了一个线程间共享变量 state。
- CyclicBarrier 并发工具
基本 LockSupport 实现线程间的阻塞和唤醒:
LockSupport.park();``LockSupport.unpark(threadB);
并发编程中的三个概念
原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
指令重排
JVM在真正执行代码的时候,不一定按照代码从上到下的真正顺序去执行,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
线程实现和创建方式
继承Thread
1 |
|
实现Runnable
1 |
|
实现Callable
(1)call()可以有返回值
(2)call()可以抛出异常,被外面捕获,获取异常信息
(3)Callable是支持泛型的
1 |
|
基于线程池的方式
(1)提高响应速度(减少创建新线程的时间)
(2)降低资源的消耗(重复利用线程池中的线程,不需要每次都创建)
(3)便于线程管理:
1 |
|
Sleep和wait的区别
- 相同点:一旦执行方法,都可以使得当前线程进入阻塞状态。
- 不同点:
- 声明位置不同:Thread类中声明
sleep()
,Object类中声明wait()
。 - 调用的方法不同:
sleep()
可以在任何需要的场景下调用,而wait()
必须在同步代码块或同步方法中调用。 - 关于释放同步监视器,:两个方法都使用在同步代码块或同步方法中,
sleep()
不会释放锁,wait()
会释放锁 wait()通常用于线程交互/通信,
sleep()通常用于暂停执行
wait()方法调用后,线程不会自动苏醒,需要别的线程调⽤同⼀个对象上的
notify()或者
notifyAll()⽅法。
sleep()⽅法执⾏完成后,线程会⾃动苏醒。或者可以使⽤
wait(longtimeout)` 超时后线程会⾃动苏醒
- 声明位置不同:Thread类中声明
说⼀说⾃⼰对于 synchronized 关键字的了解
synchronized解决了多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在Java的早期版本中,synchronized属于重量级锁,效率很低。因为监视器锁依赖底层操作系统的Mutex Lock实现,Java的线程映射到操作系统的原生线程上。所以挂起和唤醒一个线程都需要操作系统帮忙完成,时间成本很高。在Java6以后从JVM层面对synchronized有了较大的优化。
synchronized 关键字的使用
1.修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得 当前对象实例的锁
1 |
|
2.修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得 当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。所以,如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象, 因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。
1 |
|
修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized(.class) 表示进⼊同步代码前要获得 当前 class 的锁
1
2
3synchronized(this) {
//业务代码
}
总结:
- synchronized 关键字加到 static 静态⽅法和 synchronized(class) 代码块上都是是给 Class
类上锁。 - synchronized 关键字加到实例⽅法上是给对象实例上锁。
- 尽量不要使⽤ synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!
synchronized 关键字的底层原理
sychronized修饰代码块时的底层原理
synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
当执行 monitorenter
指令时,线程试图获取锁也就是获取 对象监视器 monitor
的持有
在执行monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
对象锁的的拥有者线程才可以执行 monitorexit
指令来释放锁。在执行 monitorexit
指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
sychronized修饰方法时的底层原理
synchronized
修饰的方法使用 ACC_SYNCHRONIZED
标识,表示该方法是同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
JDK1.6 之后的 synchronized 关键字底层做了哪些优化?
为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和过程。
(1)偏向锁:偏向锁是针对于一个线程而言的,线程获得锁之后就不会再有解锁等操作了,适合一个线程对一个锁的多次获取的情况
(2)轻量级锁:适合锁执行体比较简单(即减少锁粒度或时间), 自旋一会儿就可以成功获取锁的情况
死锁产生的原因
不同的线程占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,会产生死锁。
死锁的四个必要条件:
- 互斥条件:该资源任意时刻只由一个线程占用
- 请求与保持条件:一个进程因请求资源而阻塞时,对已经获得的资源保持不放。
- 不剥夺条件:已经获得的资源在没有使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干个进程之间,形成一种头尾相接的循环等待资源。
如何避免死锁的发生:(破坏四个条件)
- 破坏互斥条件:这个条件没法破坏,因为锁本身就是让资源互斥的。
- 破坏请求与保持条件:一次性申请所有资源,这样它在整个运行过程中便不会再提出资源请求,从而破坏了“请求”条件。
- 破坏不剥夺条件:线程进一步申请资源时,如果申请不到,可以主动释放它占有的资源
- 破坏循环等待条件:按某一顺序申请资源 ,破坏循环等待条件,。( 每个进程只能按递增顺序申请资源,因此每个时刻总有一个进程占据了较高序号的资源,那么它后面继续申请的资源一定是空闲的,这就保证了进程是可以一直向前推进的,)
- 每个进程只能按递增顺序申请资源,即进程申请了序号为 8 的资源后,下次只能申请序号为 9 或以上资源
- 如果进程需要同一资源类型的多个实例(也就是序号相同的资源),则必须对它们一起进行申请
- 如果进程后面又想申请序号低的资源(比如5),那就必须把现在拥有的序号为5及其以上的资源全部释放
产生死锁的代码
1 |
|
synchoronized和Reentrantlock的区别
两者都是可重入锁
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
(1)synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
(2)ReentrantLock 比 synchronized 增加了一些高级功能
- 等待可中断 :也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
- 可实现公平锁 :
ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock
默认情况是非公平的,可以通过ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来制定是否是公平的。
(3)sychronized在加锁和解锁的过程是自动进行的,而reentrantlock的加锁和解锁的过程需要手动进行。
可重入锁的实现原理
每一个锁关联一个线程持有者和计数器,当计数器为0时,表示改锁可以被任意线程所获取并调用相应的方法,当某个线程请求成功后,JVM记下锁持有的线程,并将锁的计数器记为1。此时如果其他线程请求该锁,必须等待,而持有该锁的线程如果再次请求改锁,就可以再次拿到锁,同时计数器+1,当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放锁。
synchoronized和volatile的区别
(1)、volatile只能作用于变量,使用范围较小。synchronized可以用在变量、方法、类、同步代码块等,使用范围比较广。
(2)、volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、原子性synchronized都可以包证。
(3)、volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。
(4)、在性能方面synchronized关键字是防止多个线程同时执行一段代码,就会影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized。
AQS
AQS是抽象队列同步器,通过维护一个共享资源的状态,和一个先进先出的线程等待队列来实现一个多线程访问共享资源的框架。
AQS只是一个框架 ,只定义了一个接口,具体资源的获取、释放都 由自定义同步器去实现。不同的自定义同步器争用共享资源的方式也不同,自定义同步器在实现时只需实现共享资源state的获取与释放方式即可
AQS的原理:
AQS为每个共享资源都设置一个共享资源的锁,线程在需要访问共享资源时首先需要获取共享资源锁,如果获取到了共享资源锁,便可以在当前线程中使用该共享资源,如果获取不到,则将该线程放入线程等待队列,等待下一次资源调度,
state:状态
Abstract Queued Synchronizer 维护了 volatile int 类型的变量,用于表示当前的同步状态。volatile虽然不能保证操作的原子性,但是能保证当前变量state的可见性。
state的访问方式有三种: getState()、setState()和 compareAndSetState(),均是原子操作,其中,compareAndSetState的实现依赖于 Unsafe的compareAndSwaplnt() 具体的。JDK 码实现如下:
AQS共享资源的方式:独占式和共享式
- 独占式:只有一个线程能执行,具体的 Java 实现有 ReentrantLock。
- 共享式:多个线程可同时执行,具体的 Java 实现有 Semaphore和CountDownLatch
ReentrantLock对AQS的独占方式实现为:ReentrantLock中的state初始值为0表示无锁状态。在线程执行 tryAcquire()获取该锁后ReentrantLock中的state+1,这时该线程独占ReentrantLock锁,其他线程在通过tryAcquire() 获取锁时均会失败,直到该线程释放锁后state再次为0,其他线程才有机会获取该锁。该线程在释放锁之前可以重复获取此锁,每获取一次便会执行一次state+1, 因此ReentrantLock也属于可重入锁。 但获取多少次锁就要释放多少次锁,这样才能保证state最终为0。如果获取锁的次数多于释放锁的次数,则会出现该线程一直持有该锁的情况;如果获取锁的次数少于释放锁的次数,则运行中的程序会报锁异常。
CountDownLatch对AQS的共享方式实现为:CountDownLatch 将任务分为N个子线程去执行,将 state 初始化为 N, N与线程的个数一致,N个子线程是井行执行的,每个子线程都在执行完成后 countDown()1次, state 执行 CAS 操作并减1。在所有子线程都执行完成( state=O)时会unpark()主线程,然后主线程会从 await()返回,继续执行后续的动作。
CAS
- CAS算法涉及到3个操作数:1、需要读写的内存值V;2、进行比较的值A;3、要写入的新值B。
- 当且仅当V的值等于A的值,CAS通过原子方式用B来更新V的值(比较+更新是一个原子操作)。否则不会执行。
CAS存在的问题:
ABA问题:一个线程把数据A变成了B,然后又重新变成了A,此时另一个线程读取该数据的时候,发现A没有变化,就误认为是原来的那个A,但是此时A的一些属性或状态已经发生过变化。
解决办法:可以增加版本号(AtomicStampedReference对象),内存值每次修改后,版本号+1。https://blog.csdn.net/weixin_42671172/article/details/108340791。或者使用AtomicMarkableReference对象,判断修改状态是否一致。
循环时间长开销大问题: CAS如果长时间不成功,就会导致一直自旋,给CPU带来很大的消耗。
只能保证一个共享变量的原子操作: 对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
Semaphore
Semaphore`(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
模拟 5 辆车停 3 个车位:
1 |
|
加锁解锁流程Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一 刚开始,permits(state)为 3,这时 5 个线程来获取资源
`假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列 park 阻塞
这时 Thread-4 释放了 permits,状态如下
接下来 Thread-0 竞争成功,permits 再次设置为0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态
Semaphore是一个有效的流量控制工具,它基于AQS共享锁实现。我们常常用它来控制对有限资源的访问。
- 每次使用资源前,先申请一个信号量,如果资源数不够,就会阻塞等待;
- 每次释放资源后,就释放一个信号量。
互斥量和信号量的区别
1. 互斥量用于线程的互斥,信号量用于线程的同步。
互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源
2. 互斥量值只能为0/1,信号量值可以为非负整数。
也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。
3. 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。
CountDownLatck
CountDownLatch的作用很简单,就是一个或者一组线程在开始执行操作之前,必须要等到其他线程执行完才可以。我们举一个例子来说明,在考试的时候,老师必须要等到所有人交了试卷才可以走。此时老师就相当于等待线程,而学生就好比是执行的线程。
注意:java中还有一个同步工具类叫做CyclicBarrier,他的作用和CountDownLatch类似。同样是等待其他线程都完成了,才可以进行下一步操作,我们再举一个例子,在打王者的时候,在开局前所有人都必须要加载到100%才可以进入。否则所有玩家都相互等待。
我们看一下区别:
CountDownLatch: 一个线程(或者多个), 等待另外N个线程完成某个事情之后才能执行。 CyclicBarrier : N个线程相互等待,任何一个线程完成之前,所有的线程都必须等待。关键点其实就在于那N个线程(1)CountDownLatch里面N个线程就是学生,学生做完了试卷就可以走了,不用等待其他的学生是否完成(2)CyclicBarrier 里面N个线程就是所有的游戏玩家,一个游戏玩家加载到100%还不可以,必须要等到其他的游戏玩家都加载到100%才可以开局
CountDownLatch主要使用countDown方法进行减1操作,使用await方法进行等到操作。
countDown原理
CountDownLatch里面保存了一个count值,通过减1操作,直到为0时候,等待线程才可以执行。而且通过源码也可以看到这个countDown方法其实是通过sync调用releaseShared(1)来完成的。
在这里我们发现继承了AbstractQueuedSynchronizer(AQS)。AQS的其中一个作用就是维护线程状态和获取释放锁。在这里也就是说CountDownLatch使用AQS机制维护锁状态。而releaseShared(1)方法就是释放了一个共享锁。
await原理
await()底层主要是acquireSharedInterruptibly
方法实现的,继续跟进去看看。
虚拟内存、驻留内存、共享内存
虚拟内存:
程序运行的过程中使用的内存都是虚拟的内存。每个进程都会在内存中有自己独立的page table用来,将虚拟内存地址映射到物理内存地址。
共享内存
多个进程可能会使用的共同内容,因此不同进程的page table中映射到相同的物理地址,这些公用的物理地址为共享内存。
驻留内存
虚拟内存中并不是全部映射到物理内存地址,已映射到物理内存的大小为驻留内存。
因此描述一个进程占用资源情况建议使用 驻留内存 - 共享内存
Volatile
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
保证了程序的可见性的底层原理
Volatile是通过MESI缓存一致性协议来保证可见性的
volatile关键字会开启总线得mesi缓存一致性协议。
**mesi缓存一致性协议:**当多个CPU从主内存读取同一个数据到各自的高速缓存,当其中的某个CPU修改缓存中的数据时,该数据会马上同步回主内存,其他CPU通过总线嗅探机制可以感知数据的变化从而将自己缓存里的数据失效。
MESI协议如何保证可见性?
首先cpu会根据共享变量是否带有Volatile字段,来决定是否使用MESI协议保证缓存一致性。
如果有Volatile,汇编层面会对变量加上Lock前缀,当一个线程修改变量的值后,会马上经过store、write等原子操作修改主内存的值(如果不加Lock前缀不会马上同步),为什么监听到修改会马上同步呢?就是为了触发cpu的嗅探机制,及时失效其他线程变量副本。
1.线程2修改值,以后会经过总线,然后写回主内存。
2.volatile开启总线mesi缓存一致性协议,每个cpu 都会监听总线
3.当知道其他cpu修改了变量值,立刻会失效自己工作内存中得值。
4.重新去主内存取值。
但是第四步,重新去主内存取值,怎么保障读取是最新得值呢(因为在store时可能已经经过总线,但此时还有write进主线程,总线却出发了嗅探机制,其他线程的变量已经失效,当其他线程去读取主内存的数据时,新数据还未write进来,产生脏数据!)。
加锁:
在store之前加锁(lock),锁住主内存的值,这时其他线程不能获取到主内存的值,等主内存的值更新之后锁就释放,等锁释放后线程获取的值才是最新的值。
禁止指令重排,保证程序的有序性底层原理
volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执
通过对Volatile修饰的变量增加内存屏障来完成的!
- 写屏障【给Volatile变量赋值】:确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
- 读屏障【读取Volatile变量的值】:不会将读屏障之后的代码排在读屏障之前
不保证程序的原子性
volatile只有在写操作时,才保证了原子性,因为数据操作完成后,会立即刷新主内存,但是volatile修饰的变量,可能被多个线程读取。
A线程读 i = 1同时B线程也读了i = 1,然后自增完成刷新入主内存。i的值是2。
所以如果该变量是volatile修饰的,那可以完全保证此时取到的是最新信息。但在入栈和自增计算执行过程中,该变量有可能正在被其他线程修改,最后计算出来的结果照样存在问题,
ThreadLocal是干嘛的?实现原理
ThreadLocal 表示线程的“本地变量”,即每个线程都拥有该变量副本,达到人手一份的效果,各用各的,这样就可以避免共享资源的竞争。
事实上,这就是一种“空间换时间”的方案,每个线程都拥有自己的“共享资源”无疑会让内存占用大很多,但是由于不需要同步也就减少了线程可能存在的阻塞等待,从而提高时间效率。
ThreadLocal的set方法
1 |
|
ThreadLocalMap
是通过getMap
来的:
1 |
|
其中ThreadLocals
为:
1 |
|
ThreadLocalMap
的引用是作为Thread
的一个成员变量的。根据上面的代码,当map为null的时候使用createMap方法new一个ThreadLocalMap
实例对象:
1 |
|
set()
总结:
通过当前线程获取所维护的ThreadLocalMap,如果ThreadLocalMap不为空,则以ThreadLocal实例为key,值为value的键值对存入ThreadLocalMap,若ThreadLocalMap为空,就新创建一个ThreadLocalMap,然后以ThreadLocal为键,value为值
Thread的get方法
1 |
|
通过当前线程 thread 实例获取到它所维护的 ThreadLocalMap,然后以当前 ThreadLocal 实例为 key 获取该 map 中的键值对(Entry),如果 Entry 不为 null 则返回 Entry 的 value。如果获取 ThreadLocalMap 为 null 或者 Entry 为 null 的话,就以当前 ThreadLocal 为 Key,value 为 null 存入 map 后,并返回 null。
Thread的remove方法
1 |
|
先获取与当前线程相关联的 ThreadLocalMap,然后从 map 中删除该 ThreadLocal 实例为 key 的键值对即可
ThreadLocalMap详解
从ThreadLocal
中的set
、get
和remove
方法中可以知道,数据其实都放在了 ThreadLocalMap
中,ThreadLocal
的 get
、set
和 remove
方法实际上都是通过 ThreadLocalMap
的 getEntry
、set
和 remove
方法实现的。如果想真正全方位的弄懂 ThreadLocal
,势必得再对 ThreadLocalMap
做一番理解。
ThreadLocalMap中的Entry数据结构
1 |
|
- 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收
Entry 是一个以 ThreadLocal 为 key,Object 为 value 的键值对,另外需要注意的是这里的ThreadLocal 是弱引用,因为 Entry 继承了 WeakReference,在 Entry 的构造方法中,调用了 super(k)方法,会将 ThreadLocal 实例包装成一个 WeakReferenece。
内存泄漏
可以用一个图来理解下 Thread、ThreadLocal、ThreadLocalMap、Entry 之间的关系:
上图中虚线是弱引用,实线是强引用,如果ThreadLocal中的外部强引用变为null,。则当GC操作的时候ThreadLocal就会被回收,则key为null。这样一来,ThreadLocalMap中就会出现key为null的Entry,没办法访问这些key为null的value。如果当前线程不结束的话,一直回存在一条强的引用连,无法回收,造成内存泄漏。
怎么解决内存泄漏
(1)每次使用完ThreadLocal
后,调用remove()
方法进行清除数据
(2)将ThreadLocal
变量定义为private static
,这样就一直存在ThreadLocal
的强引用了,也能保证任何时候都能通过ThreadLocal
的弱引用访问到Entry
的value
值,进而清除掉。
ThreadLocalMap中的set方法
与 ConcurrentHashMap、HashMap 等容器一样,ThreadLocalMap 也是采用散列表进行实现的。
只不过ThreadLocalMap是使用开放地址法来解决哈希冲突的。
1 |
|
1 |
|
nextHashCode()
:该方法实际上是用一个 AtomicInteger
加上 0x61c88647
来实现的。
0x61c88647
这个数是有特殊意义的,它能够保证 hash 表的每个散列桶能够均匀的分布,这是Fibonacci Hashing
怎样确定新值插入到哈希表中的位置?
key.ThreadLocalHashCode & (len-1)
:利用当前 key(即 ThreadLocal 实例)的 hashcode 与哈希表大小相与(因为哈希表大小总是为 2 的幂次方,所以相与等同于一个取模的过程)
怎样解决 hash 冲突?
源码中通过nextIndex(i, len)
方法解决 hash 冲突的问题,该方法中的((i + 1 < len) ? i + 1 : 0);
,也就是不断往后线性探测,当到哈希表末尾的时候再从 0 开始,成环形。
怎样解决“脏”Entry?
在 set 方法的 for 循环中寻找和当前 Key 相同的可覆盖 entry 的过程中通过replaceStaleEntry方法解决脏 entry 的问题。
如果当前table[i]
为 null 的话,直接插入新 entry 后也会通过cleanSomeSlots来解决脏 entry 的问题,
如何进行扩容?
这里ThreadLocalMap 初始大小为 16,加载因子为 2/3,所以哈希表可用大小为:16*2/3=10,即哈希表可用容量为 10。
resize方法代码为:
1 |
|
新建一个大小为原来数组长度的两倍的数组,然后遍历旧数组中的 entry 并将其插入到新的 hash 数组中,需要注意的是,在扩容的过程中针对脏 entry 的话会把 value 设为 null,以便能够被垃圾回收器回收,解决隐藏的内存泄漏的问题。
ThreadLocalMap中getEntry方法
1 |
|
如果当前定位的 entry 的 key 和查找的 key 相同的话就直接返回这个 entry,否则的话就是在 set 的时候判断是否存在 hash 冲突,需要通过 getEntryAfterMiss 做进一步处理。getEntryAfterMiss 方法为:
1 |
|
ThreadLocalMap中的remove方法
1 |
|
该方法逻辑很简单,通过往后环形查找到与指定 key 相同的 entry 后,先通过 clear 方法将 key 置为 null 后,使其转换为一个脏 entry,然后调用 expungeStaleEntry 方法将其 value 置为 null,以便垃圾回收时能够清理,同时将 table[i]置为 null。
多线程的顺序执行
join方式
1 |
|
CountDownLatch实现
CountDownLatch通过计数器提供了更灵活的控制,只要检测到计数器为0当前线程就可以往下执行而不用管相应的thread是否执行完毕。
1 |
|
创建单线程池实现
1 |
|