Java 并发夺命 23 问
本文已经收录到 Github 仓库,该仓库包含计算机基础、Java 基础、多线程、JVM、数据库、Redis、Spring、Mybatis、SpringMVC、SpringBoot、分布式、微服务、设计模式、架构、校招社招分享等核心知识点,欢迎 star~
Github 地址:https://github.com/Tyson0314/Java-learning
线程池
线程池:一个管理线程的池子。
为什么平时都是使用线程池创建线程,直接 new 一个线程不好吗?
嗯,手动创建线程有两个缺点
不受控风险
频繁创建开销大
为什么不受控?
系统资源有限,每个人针对不同业务都可以手动创建线程,并且创建线程没有统一标准,比如创建的线程有没有名字等。当系统运行起来,所有线程都在抢占资源,毫无规则,混乱场面可想而知,不好管控。
频繁手动创建线程为什么开销会大?跟 new Object() 有什么差别?
虽然 Java 中万物皆对象,但是 new Thread() 创建一个线程和 new Object()还是有区别的。
new Object()过程如下:
JVM 分配一块内存 M
在内存 M 上初始化该对象
将内存 M 的地址赋值给引用变量 obj
创建线程的过程如下:
JVM 为一个线程栈分配内存,该栈为每个线程方法调用保存一个栈帧
每一栈帧由一个局部变量数组、返回值、操作数堆栈和常量池组成
每个线程获得一个程序计数器,用于记录当前虚拟机正在执行的线程指令地址
系统创建一个与 Java 线程对应的本机线程
将与线程相关的描述符添加到 JVM 内部数据结构中
线程共享堆和方法区域
创建一个线程大概需要 1M 左右的空间(Java8,机器规格 2c8G)。可见,频繁手动创建/销毁线程的代价是非常大的。
为什么使用线程池?
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。
提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。
线程池执行原理?
当线程池里存活的线程数小于核心线程数
corePoolSize
时,这时对于一个新提交的任务,线程池会创建一个线程去处理任务。当线程池里面存活的线程数小于等于核心线程数corePoolSize
时,线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime
,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。当线程池里面存活的线程数已经等于 corePoolSize 了,这是对于一个新提交的任务,会被放进任务队列 workQueue 排队等待执行。
当线程池里面存活的线程数已经等于
corePoolSize
了,并且任务队列也满了,假设maximumPoolSize>corePoolSize
,这时如果再来新的任务,线程池就会继续创建新的线程来处理新的任务,知道线程数达到maximumPoolSize
,就不会再创建了。如果当前的线程数达到了
maximumPoolSize
,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个 RejectedExecutionException 异常。
线程池参数有哪些?
ThreadPoolExecutor 的通用构造函数:
1、corePoolSize
:当有新任务时,如果线程池中线程数没有达到线程池的基本大小,则会创建新的线程执行任务,否则将任务放入阻塞队列。当线程池中存活的线程数总是大于 corePoolSize 时,应该考虑调大 corePoolSize。
2、maximumPoolSize
:当阻塞队列填满时,如果线程池中线程数没有超过最大线程数,则会创建新的线程运行任务。否则根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过 keepAliveTime 之后,就应该退出,避免资源浪费。
3、BlockingQueue
:存储等待运行的任务。
4、keepAliveTime
:非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为 0,表示多余的空闲线程会被立即终止。
5、TimeUnit
:时间单位
6、ThreadFactory
:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。
7、RejectedExecutionHandler
:当队列和线程池都满了的时候,根据拒绝策略处理新任务。
线程池大小怎么设置?
如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢,会影响用户体验,甚至会出现任务队列大量堆积任务导致 OOM。
如果线程池线程数量过大,大量线程可能会同时抢占 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了执行效率。
CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1
,多出来的一个线程是为了防止某些原因导致的线程阻塞(如 IO 操作,线程 sleep,等待锁)而带来的影响。一旦某个线程被阻塞,释放了 CPU 资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
I/O 密集型任务(2N): 系统的大部分时间都在处理 IO 操作,此时线程可能会被阻塞,释放 CPU 资源,这时就可以将 CPU 交出给其它线程使用。因此在 IO 密集型任务的应用中,可以多配置一些线程,具体的计算方法:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (IO耗时/CPU耗时))
,一般可设置为 2N。
线程池的类型有哪些?适用场景?
常见的线程池有 FixedThreadPool
、SingleThreadExecutor
、CachedThreadPool
和 ScheduledThreadPool
。这几个都是 ExecutorService
线程池实例。
FixedThreadPool
固定线程数的线程池。任何时间点,最多只有 nThreads 个线程处于活动状态执行任务。
使用无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),运行中的线程池不会拒绝任务,即不会调用 RejectedExecutionHandler.rejectedExecution()方法。
maxThreadPoolSize 是无效参数,故将它的值设置为与 coreThreadPoolSize 一致。
keepAliveTime 也是无效参数,设置为 0L,因为此线程池里所有线程都是核心线程,核心线程不会被回收(除非设置了 executor.allowCoreThreadTimeOut(true))。
适用场景:适用于处理 CPU 密集型的任务,确保 CPU 在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。需要注意的是,FixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM。
SingleThreadExecutor
只有一个线程的线程池。
使用无界队列 LinkedBlockingQueue。线程池只有一个运行的线程,新来的任务放入工作队列,线程处理完任务就循环从队列里获取任务执行。保证顺序的执行各个任务。
适用场景:适用于串行执行任务的场景,一个任务一个任务地执行。在任务比较多的时候也是会导致 OOM。
CachedThreadPool
根据需要创建新线程的线程池。
如果主线程提交任务的速度高于线程处理任务的速度时,CachedThreadPool
会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
使用没有容量的 SynchronousQueue 作为线程池工作队列,当线程池有空闲线程时,SynchronousQueue.offer(Runnable task)
提交的任务会被空闲线程处理,否则会创建新的线程处理任务。
适用场景:用于并发执行大量短期的小任务。CachedThreadPool
允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
ScheduledThreadPoolExecutor
在给定的延迟后运行任务,或者定期执行任务。在实际项目中基本不会被用到,因为有其他方案选择比如quartz
。
使用的任务队列 DelayQueue
封装了一个 PriorityQueue
,PriorityQueue
会对队列中的任务进行排序,时间早的任务先被执行(即ScheduledFutureTask
的 time
变量小的先执行),如果 time 相同则先提交的任务会被先执行(ScheduledFutureTask
的 squenceNumber
变量小的先执行)。
执行周期任务步骤:
线程从
DelayQueue
中获取已到期的ScheduledFutureTask(DelayQueue.take())
。到期任务是指ScheduledFutureTask
的 time 大于等于当前系统的时间;执行这个
ScheduledFutureTask
;修改
ScheduledFutureTask
的 time 变量为下次将要被执行的时间;把这个修改 time 之后的
ScheduledFutureTask
放回DelayQueue
中(DelayQueue.add()
)。
适用场景:周期性执行任务的场景,需要限制线程数量的场景。
进程线程
进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间。
线程是比进程更小的执行单位,它是在一个进程中独立的控制流,一个进程可以启动多个线程,每条线程并行执行不同的任务。
线程的生命周期
初始(NEW):线程被构建,还没有调用 start()。
运行(RUNNABLE):包括操作系统的就绪和运行两种状态。
阻塞(BLOCKED):一般是被动的,在抢占资源中得不到资源,被动的挂起在内存,等待资源释放将其唤醒。线程被阻塞会释放 CPU,不释放内存。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时间后自行返回。
终止(TERMINATED):表示该线程已经执行完毕。
图片来源:Java 并发编程的艺术
讲讲线程中断?
线程中断即线程运行过程中被其他线程给打断了,它与 stop 最大的区别是:stop 是由系统强制终止线程,而线程中断则是给目标线程发送一个中断信号,如果目标线程没有接收线程中断的信号并结束线程,线程则不会终止,具体是否退出或者执行其他逻辑取决于目标线程。
线程中断三个重要的方法:
1、java.lang.Thread#interrupt
调用目标线程的interrupt()
方法,给目标线程发一个中断信号,线程被打上中断标记。
2、java.lang.Thread#isInterrupted()
判断目标线程是否被中断,不会清除中断标记。
3、java.lang.Thread#interrupted
判断目标线程是否被中断,会清除中断标记。
创建线程有哪几种方式?
通过扩展
Thread
类来创建多线程通过实现
Runnable
接口来创建多线程实现
Callable
接口,通过FutureTask
接口创建线程。使用
Executor
框架来创建线程池。
继承 Thread 创建线程代码如下。run()方法是由 jvm 创建完操作系统级线程后回调的方法,不可以手动调用,手动调用相当于调用普通方法。
Runnable 创建线程代码:
实现 Runnable 接口比继承 Thread 类所具有的优势:
可以避免 java 中的单继承的限制
线程池只能放入实现 Runable 或 Callable 类线程,不能直接放入继承 Thread 的类
Callable 创建线程代码:
使用 Executor 创建线程代码:
什么是线程死锁?
线程死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方持有的资源,所以这两个线程就会互相等待而进入死锁状态。
下面通过例子说明线程死锁,代码来自并发编程之美。
代码输出如下:
线程 A 通过 synchronized
(resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000)
。让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。
线程死锁怎么产生?怎么避免?
死锁产生的四个必要条件:
互斥:一个资源每次只能被一个进程使用
请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源
不剥夺:进程已获得的资源,在未使用之前,不能强行剥夺
循环等待:进程之间循环等待着资源
避免死锁的方法:
互斥条件不能破坏,因为加锁就是为了保证互斥
一次性申请所有的资源,避免线程占有资源而且在等待其他资源
占有部分资源的线程进一步申请其他资源时,如果申请不到,主动释放它占有的资源
按序申请资源
线程 run 和 start 的区别?
当程序调用
start()
方法,将会创建一个新线程去执行run()
方法中的代码。run()
就像一个普通方法一样,直接调用run()
的话,不会创建新线程。一个线程的
start()
方法只能调用一次,多次调用会抛出 java.lang.IllegalThreadStateException 异常。run()
方法则没有限制。
线程都有哪些方法?
start
用于启动线程。
getPriority
获取线程优先级,默认是 5,线程默认优先级为 5,如果不手动指定,那么线程优先级具有继承性,比如线程 A 启动线程 B,那么线程 B 的优先级和线程 A 的优先级相同
setPriority
设置线程优先级。CPU 会尽量将执行资源让给优先级比较高的线程。
interrupt
告诉线程,你应该中断了,具体到底中断还是继续运行,由被通知的线程自己处理。
当对一个线程调用 interrupt() 时,有两种情况:
如果线程处于被阻塞状态(例如处于 sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个 InterruptedException 异常。
如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true。不过,被设置中断标志的线程可以继续正常运行,不受影响。
interrupt() 并不能真正的中断线程,需要被调用的线程自己进行配合才行。
join
等待其他线程终止。在当前线程中调用另一个线程的 join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
yield
暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
sleep
使线程转到阻塞状态。millis 参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,线程自动转为 Runnable 状态。
volatile 底层原理
volatile
是轻量级的同步机制,volatile
保证变量对所有线程的可见性,不保证原子性。
当对
volatile
变量进行写操作的时候,JVM 会向处理器发送一条LOCK
前缀的指令,将该变量所在缓存行的数据写回系统内存。由于缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。
来看看缓存一致性协议是什么。
缓存一致性协议:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,就会从内存重新读取。
volatile
关键字的两个作用:
保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
禁止进行指令重排序。
指令重排序是 JVM 为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java 编译器会在生成指令系列时在适当的位置会插入
内存屏障
指令来禁止处理器重排序。插入一个内存屏障,相当于告诉 CPU 和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个 volatile 字段进行写操作,Java 内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。
synchronized 的用法有哪些?
修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁
修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
synchronized 的作用有哪些?
原子性:确保线程互斥的访问同步代码;
可见性:保证共享变量的修改能够及时可见;
有序性:有效解决重排序问题。
synchronized 底层实现原理?
synchronized 同步代码块的实现是通过 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。当执行 monitorenter
指令时,线程试图获取锁也就是获取 monitor
的持有权(monitor 对象存在于每个 Java 对象的对象头中, synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因)。
其内部包含一个计数器,当计数器为 0 则可以成功获取,获取后将锁计数器设为 1 也就是加 1。相应的在执行 monitorexit
指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized 修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
volatile 和 synchronized 的区别是什么?
volatile
只能使用在变量上;而synchronized
可以在类,变量,方法和代码块上。volatile
至保证可见性;synchronized
保证原子性与可见性。volatile
禁用指令重排序;synchronized
不会。volatile
不会造成阻塞;synchronized
会。
ReentrantLock 和 synchronized 区别
使用 synchronized 关键字实现同步,线程执行完同步代码块会自动释放锁,而 ReentrantLock 需要手动释放锁。
synchronized 是非公平锁,ReentrantLock 可以设置为公平锁。
ReentrantLock 上等待获取锁的线程是可中断的,线程可以放弃等待锁。而 synchonized 会无限期等待下去。
ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回 true,否则返回 false。
wait()和 sleep()的异同点?
相同点:
它们都可以使当前线程暂停运行,把机会交给其他线程
任何线程在调用 wait()和 sleep()之后,在等待期间被中断都会抛出
InterruptedException
不同点:
wait()
是 Object 超类中的方法;而sleep()
是线程 Thread 类中的方法对锁的持有不同,
wait()
会释放锁,而sleep()
并不释放锁唤醒方法不完全相同,
wait()
依靠notify
或者notifyAll
、中断、达到指定时间来唤醒;而sleep()
到达指定时间被唤醒调用
wait()
需要先获取对象的锁,而Thread.sleep()
不用
Runnable 和 Callable 有什么区别?
Callable 接口方法是
call()
,Runnable 的方法是run()
;Callable 接口 call 方法有返回值,支持泛型,Runnable 接口 run 方法无返回值。
Callable 接口
call()
方法允许抛出异常;而 Runnable 接口run()
方法不能继续上抛异常。
线程执行顺序怎么控制?
假设有 T1、T2、T3 三个线程,你怎样保证 T2 在 T1 执行完后执行,T3 在 T2 执行完后执行?
可以使用 join 方法解决这个问题。比如在线程 A 中,调用线程 B 的 join 方法表示的意思就是**:A 等待 B 线程执行完毕后(释放 CPU 执行权),在继续执行。**
代码如下:
运行结果:
守护线程是什么?
守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。在 Java 中垃圾回收线程就是特殊的守护线程。
线程间通信方式
1、使用 Object 类的 wait()/notify()。Object 类提供了线程间通信的方法:wait()
、notify()
、notifyAll()
,它们是多线程通信的基础。其中,wait/notify
必须配合 synchronized
使用,wait 方法释放锁,notify 方法不释放锁。wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了notify()
,notify 并不释放锁,只是告诉调用过wait()
的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放,调用 wait()
的一个或多个线程就会解除 wait 状态,重新参与竞争对象锁,程序如果可以再次得到锁,就可以继续向下运行。
2、使用 volatile 关键字。基于 volatile 关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
3、使用 JUC 工具类 CountDownLatch。jdk1.5 之后在java.util.concurrent
包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch
基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。
4、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport
是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。
ThreadLocal
线程本地变量。当使用ThreadLocal
维护变量时,ThreadLocal
为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。
ThreadLocal 原理
每个线程都有一个ThreadLocalMap
(ThreadLocal
内部类),Map 中元素的键为ThreadLocal
,而值对应线程的变量副本。
调用threadLocal.set()
-->调用getMap(Thread)
-->返回当前线程的ThreadLocalMap<ThreadLocal, value>
-->map.set(this, value)
,this 是threadLocal
本身。源码如下:
调用get()
-->调用getMap(Thread)
-->返回当前线程的ThreadLocalMap<ThreadLocal, value>
-->map.getEntry(this)
,返回value
。源码如下:
threadLocals
的类型ThreadLocalMap
的键为ThreadLocal
对象,因为每个线程中可有多个threadLocal
变量,如longLocal
和stringLocal
。
ThreadLocal
并不是用来解决共享资源的多线程访问问题,因为每个线程中的资源只是副本,不会共享。因此ThreadLocal
适合作为线程上下文变量,简化线程内传参。
ThreadLocal 内存泄漏的原因?
每个线程都有⼀个ThreadLocalMap
的内部属性,map 的 key 是ThreaLocal
,定义为弱引用,value 是强引用类型。垃圾回收的时候会⾃动回收 key,而 value 的回收取决于 Thread 对象的生命周期。一般会通过线程池的方式复用线程节省资源,这也就导致了线程对象的生命周期比较长,这样便一直存在一条强引用链的关系:Thread
--> ThreadLocalMap
-->Entry
-->Value
,随着任务的执行,value 就有可能越来越多且无法释放,最终导致内存泄漏。
解决⽅法:每次使⽤完ThreadLocal
就调⽤它的remove()
⽅法,手动将对应的键值对删除,从⽽避免内存泄漏。
ThreadLocal 使用场景有哪些?
场景 1
ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保了线程安全。
这种场景通常用于保存线程不安全的工具类,典型的使用的类就是 SimpleDateFormat。
假如需求为 500 个线程都要用到 SimpleDateFormat,使用线程池来实现线程的复用,否则会消耗过多的内存等资源,如果我们每个任务都创建了一个 simpleDateFormat 对象,也就是说,500 个任务对应 500 个 simpleDateFormat 对象。但是这么多对象的创建是有开销的,而且这么多对象同时存在在内存中也是一种内存的浪费。可以将 simpleDateFormat 对象给提取了出来,变成静态变量,但是这样一来就会有线程不安全的问题。我们想要的效果是,既不浪费过多的内存,同时又想保证线程安全。此时,可以使用 ThreadLocal 来达到这个目的,每个线程都拥有一个自己的 simpleDateFormat 对象。
场景 2
ThreadLocal 用作每个线程内需要独立保存信息,以便供其他方法更方便地获取该信息的场景。每个线程获取到的信息可能都是不一样的,前面执行的方法保存了信息后,后续方法可以通过 ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念。
比如 Java web 应用中,每个线程有自己单独的Session
实例,就可以使用ThreadLocal
来实现。
AQS 原理
AQS,AbstractQueuedSynchronizer
,抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch
。
AQS 使用一个volatile
的 int 类型的成员变量state
来表示同步状态,通过 CAS 修改同步状态的值。当线程调用 lock 方法时 ,如果 state
=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state
加 1。如果 state
不为 0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
同步器依赖内部的同步队列(一个 FIFO 双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态(独占或共享 )构造成为一个节点(Node)并将其加入同步队列并进行自旋,当同步状态释放时,会把首节点中的后继节点对应的线程唤醒,使其再次尝试获取同步状态。
ReentrantLock 是如何实现可重入性的?
ReentrantLock
内部自定义了同步器 sync,在加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程 ID 和当前请求的线程 ID 是否 一致,如果一致,同步状态加 1,表示锁被当前线程获取了多次。
源码如下:
锁的分类
公平锁与非公平锁
按照线程访问顺序获取对象锁。synchronized
是非公平锁,Lock
默认是非公平锁,可以设置为公平锁,公平锁会影响性能。
共享式与独占式锁
共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。
悲观锁与乐观锁
悲观锁,每次访问资源都会加锁,执行完同步代码释放锁,synchronized
和ReentrantLock
属于悲观锁。
乐观锁,不会锁定资源,所有的线程都能访问并修改同一个资源,如果没有冲突就修改成功并退出,否则就会继续循环尝试。乐观锁最常见的实现就是CAS
。
适用场景:
悲观锁适合写操作多的场景。
乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。
乐观锁有什么问题?
乐观锁避免了悲观锁独占对象的问题,提高了并发性能,但它也有缺点:
乐观锁只能保证一个共享变量的原子操作。
长时间自旋可能导致开销大。假如 CAS 长时间不成功而一直自旋,会给 CPU 带来很大的开销。
ABA 问题。CAS 的原理是通过比对内存值与预期值是否一样而判断内存值是否被改过,但是会有以下问题:假如内存值原来是 A, 后来被一条线程改为 B,最后又被改成了 A,则 CAS 认为此内存值并没有发生改变。可以引入版本号解决这个问题,每次变量更新都把版本号加一。
什么是 CAS?
CAS 全称Compare And Swap
,比较与交换,是乐观锁的主要实现方式。CAS 在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock
内部的 AQS 和原子类内部都使用了 CAS。
CAS 算法涉及到三个操作数:
需要读写的内存值 V。
进行比较的值 A。
要写入的新值 B。
只有当 V 的值等于 A 时,才会使用原子方式用新值 B 来更新 V 的值,否则会继续重试直到成功更新值。
以AtomicInteger
为例,AtomicInteger
的getAndIncrement()
方法底层就是 CAS 实现,关键代码是 compareAndSwapInt(obj, offset, expect, update)
,其含义就是,如果obj
内的value
和expect
相等,就证明没有其他线程改变过这个变量,那么就更新它为update
,如果不相等,那就会继续重试直到成功更新值。
CAS 存在的问题?
CAS 三大问题:
ABA 问题。CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA 问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从
A-B-A
变成了1A-2B-3A
。JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,原子更新带有版本号的引用类型。
循环时间长开销大。CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销。
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。
Java 从 1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。
并发工具
在 JDK 的并发包里提供了几个非常有用的并发工具类。CountDownLatch、CyclicBarrier 和 Semaphore 工具类提供了一种并发流程控制的手段。
CountDownLatch
CountDownLatch 用于某个线程等待其他线程执行完任务再执行,与 thread.join()功能类似。常见的应用场景是开启多个线程同时执行某个任务,等到所有任务执行完再执行特定操作,如汇总统计结果。
运行结果:
CyclicBarrier
CyclicBarrier(同步屏障),用于一组线程互相等待到某个状态,然后这组线程再同时执行。
参数 parties 指让多少个线程或者任务等待至某个状态;参数 barrierAction 为当这些线程都达到某个状态时会执行的内容。
运行结果如下,可以看出 CyclicBarrier 是可以重用的:
当四个线程都到达 barrier 状态后,会从四个线程中选择一个线程去执行 Runnable。
CyclicBarrier 和 CountDownLatch 区别
CyclicBarrier 和 CountDownLatch 都能够实现线程之间的等待。
CountDownLatch 用于某个线程等待其他线程执行完任务再执行。CyclicBarrier 用于一组线程互相等待到某个状态,然后这组线程再同时执行。CountDownLatch 的计数器只能使用一次,而 CyclicBarrier 的计数器可以使用 reset()方法重置,可用于处理更为复杂的业务场景。
Semaphore
Semaphore 类似于锁,它用于控制同时访问特定资源的线程数量,控制并发线程数。
运行结果如下,可以看出并非按照线程访问顺序获取资源的锁,即
原子类
基本类型原子类
使用原子的方式更新基本类型
AtomicInteger:整型原子类
AtomicLong:长整型原子类
AtomicBoolean :布尔型原子类
AtomicInteger 类常用的方法:
AtomicInteger 类主要利用 CAS (compare and swap) 保证原子操作,从而避免加锁的高开销。
数组类型原子类
使用原子的方式更新数组里的某个元素
AtomicIntegerArray:整形数组原子类
AtomicLongArray:长整形数组原子类
AtomicReferenceArray :引用类型数组原子类
AtomicIntegerArray 类常用方法:
引用类型原子类
AtomicReference:引用类型原子类
AtomicStampedReference:带有版本号的引用类型原子类。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来
为什么要使用 Executor 线程池框架呢?
每次执行任务都通过 new Thread()去创建线程,比较消耗性能,创建一个线程是比较耗时、耗资源的
调用 new Thread()创建的线程缺乏管理,可以无限制的创建,线程之间的相互竞争会导致过多占用系统资源而导致系统瘫痪
直接使用 new Thread()启动的线程不利于扩展,比如定时执行、定期执行、定时定期执行、线程中断等都不好实现
如何停止一个正在运行的线程?
使用共享变量的方式。共享变量可以被多个执行相同任务的线程用来作为是否停止的信号,通知停止线程的执行。
使用 interrupt 方法终止线程。当一个线程被阻塞,处于不可运行状态时,即使主程序中将该线程的共享变量设置为 true,但该线程此时根本无法检查循环标志,当然也就无法立即中断。这时候可以使用 Thread 提供的 interrupt()方法,因为该方法虽然不会中断一个正在运行的线程,但是它可以使一个被阻塞的线程抛出一个中断异常,从而使线程提前结束阻塞状态。
什么是 Daemon 线程?
后台(daemon)线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。必须在线程启动之前调用 setDaemon()方法,才能把它设置为后台线程。
注意:后台进程在不执行 finally 子句的情况下就会终止其 run()方法。
比如:JVM 的垃圾回收线程就是 Daemon 线程,Finalizer 也是守护线程。
SynchronizedMap 和 ConcurrentHashMap 有什么区别?
SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访问 map。
JDK1.8 ConcurrentHashMap 采用 CAS 和 synchronized 来保证并发安全。数据结构采用数组+链表/红黑二叉树。synchronized 只锁定当前链表或红黑二叉树的首节点,支持并发访问、修改。另外 ConcurrentHashMap 使用了一种不同的迭代方式。当 iterator 被创建后集合再发生改变就不再是抛出 ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据 ,iterator 完成后再将头指针替换为新的数据 ,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变。
怎么判断线程池的任务是不是执行完了?
有几种方法:
1、使用线程池的原生函数 isTerminated();
executor 提供一个原生函数 isTerminated()来判断线程池中的任务是否全部完成。如果全部完成返回 true,否则返回 false。
2、使用重入锁,维持一个公共计数。
所有的普通任务维持一个计数器,当任务完成时计数器加一(这里要加锁),当计数器的值等于任务数时,这时所有的任务已经执行完毕了。
3、使用 CountDownLatch。
它的原理跟第二种方法类似,给 CountDownLatch 一个计数值,任务执行完毕后,调用 countDown()执行计数值减一。最后执行的任务在调用方法的开始调用 await()方法,这样整个任务会阻塞,直到这个计数值为零,才会继续执行。
这种方式的缺点就是需要提前知道任务的数量。
4、submit 向线程池提交任务,使用 Future 判断任务执行状态。
使用 submit 向线程池提交任务与 execute 提交不同,submit 会有 Future 类型的返回值。通过 future.isDone()方法可以知道任务是否执行完成。
什么是 Future?
在并发编程中,不管是继承 thread 类还是实现 runnable 接口,都无法保证获取到之前的执行结果。通过实现 Callback 接口,并用 Future 可以来接收多线程的执行结果。
Future 表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加 Callback 以便在任务执行成功或失败后作出相应的操作。
举个例子:比如去吃早点时,点了包子和凉菜,包子需要等 3 分钟,凉菜只需 1 分钟,如果是串行的一个执行,在吃上早点的时候需要等待 4 分钟,但是因为你在等包子的时候,可以同时准备凉菜,所以在准备凉菜的过程中,可以同时准备包子,这样只需要等待 3 分钟。Future 就是后面这种执行模式。
Future 接口主要包括 5 个方法:
get()方法可以当任务结束后返回一个结果,如果调用时,工作还没有结束,则会阻塞线程,直到任务执行完毕
get(long timeout,TimeUnit unit)做多等待 timeout 的时间就会返回结果
cancel(boolean mayInterruptIfRunning)方法可以用来停止一个任务,如果任务可以停止(通过 mayInterruptIfRunning 来进行判断),则可以返回 true,如果任务已经完成或者已经停止,或者这个任务无法停止,则会返回 false。
isDone()方法判断当前方法是否完成
isCancel()方法判断当前方法是否取消
最后给大家分享一个 Github 仓库,上面有大彬整理的 300 多本经典的计算机书籍 PDF,包括 C 语言、C++、Java、Python、前端、数据库、操作系统、计算机网络、数据结构和算法、机器学习、编程人生等,可以 star 一下,下次找书直接在上面搜索,仓库持续更新中~
Github 地址:https://github.com/Tyson0314/java-books
评论