一、概念辨析
-
程序是指令和数据的有序集合,其本身没有任何运行的含义,是一个静态的概念。
-
进程是程序的一次执行过程,它是一个动态的概念,是系统资源分配的单位。
-
一个进程中包含多个线程,当然,一个进程也至少包含一个线程,线程是CPU调度和执行的单位。
注意:很多多线程是模拟出来的,真正的多线程是指有多个CPU,即多核,如服务器。如果是模拟出来的多线程,即在一个CPU的情况下,在同一个时间点,CPU只能执行一个代码,因为切换地很快,所以就有同时执行的错觉。
- 守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。在Java中垃圾回收线程就是特殊的守护线程
1、核心概念
- 线程就是独立的执行路径;
- 在程序运行时,即使没有自己创建线程,后台也会有多个线程,如主线程,GC线程;
- main()称之为主线程,为系统的入口,用于执行整个程序;
- 在一个进程中,如果开辟了多个线程,线程的运行由调度器安排调度,调度器是与操作系统紧密相关的,会存在资源抢夺的问题,需要加入并发控制;
- 对同一份资源操作时,会存在资源抢夺的问题,需要加入并发控制;
- 线程会带来额外的开销,如CPU调度时间,并发控制开销
- 每个线程在自己的工作内存交互,内存控制不当会造成数据不一致
2、并行和并发
- 并行:多个处理器或者多核处理器同时处理多个任务
- 并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
二、线程创建的三种方式
1、继承Thread类
- 创建一个新的类继承Thread类
- 重写run()方法
- 调用start()方法开启线程,同时和main的主线程交替执行
- 如果是调用run()方法,则是按照代码的执行顺序来执行
注意:线程开启不代表立即执行,线程的执行由CPU调度
2、实现Runnable接口
推荐使用实现Runnable接口,而不是继承Thread类,因为Java单继承的局限性
- 定义一个类实现Runnable接口
- 重写run()方法
- 在主函数中创建出该类的实例
- 再创建一个线程对象,通过线程对象来开启线程,代理
- 最后再调用start()方法
3、Callable接口(了解)
Runnable和Callable有什么区别?
Runnable没有返回值,Callable可以拿到返回值,可以认为Callable是Runnable的补充
三、一些方法的辨析
1、线程的 run() 和 start() 有什么区别?
-
start()方法用于启动线程,run() 方法用于执行线程的运行时代码。
-
run() 可以重复调用,而 start() 只能调用一次。
2、sleep()和wait()有什么区别?
- 类的不同:sleep() 来自 Thread,wait() 来自 Object。
- 释放锁:sleep() 不释放锁;wait() 释放锁。
- 用法不同:sleep() 时间到会自动恢复;wait() 可以使用 notify()/notifyAll()直接唤醒。
3、notify()和 notifyAll()有什么区别?
- notifyAll()会唤醒所有的线程,notify()之后唤醒一个线程。
- notifyAll() 调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争;而 notify()只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。
四、线程同步
1、什么是线程同步?
当使用多个线程来访问同一个数据时,将会导致数据不准确(数据不一致),相互之间产生冲突,非常容易出现线程安全问题
线程同步的真实意思其实是“排队”:几个线程之间要排队,一个一个对共享资源进行操作,而不是同时进行操作。
所以,用同步机制来解决这些问题,加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。
2、线程同步的几种方式
1.使用synchronized
同步方法,使用 synchronized关键字,可以修饰普通方法、静态方法,以及语句块。
同步代码块
用synchronized关键字修饰语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步
synchronized(对象) {
//得到对象的锁,才能操作同步代码
需要被同步代码;
//通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。
}
2.使用ReentrantLock
ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法具有相同的基本行为和语义,并且扩展了其能力。
synchronized 与 Lock 的对比
-
ReentrantLock是显示锁,手动开启和关闭锁,别忘记关闭锁;
-
synchronized 是隐式锁,出了作用域自动释放;
-
ReentrantLock只有代码块锁,synchronized有代码块锁和方法锁;
-
使用 ReentrantLock锁,JVM 将花费较少的时间来调度线程,线程更好,并且具有更好的扩展性(提供更多的子类)
优先使用顺序:ReentrantLock > synchronized 同步代码块 > synchronized 同步方法
3.使用原子变量实现线程同步
为了完成线程同步,我们将使用原子变量(Atomic*开头的)**来实现。
比如典型代表:AtomicInteger类存在于java.util.concurrent.atomic中,该类表示支持原子操作的整数,采用getAndIncrement方法以原子方法将当前的值递加。
4.ThreadLocal实现线程同步
如果使用ThreadLocal管理变量,则每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响,从而实现线程同步。
ThreadLocal 的经典使用场景是数据库连接和 session 管理等。
3、说说线程同步几种实现方式的原理
1.说一下atomic的原理?
- atomic通过CAS ( Compare And Swap)乐观锁机制-自旋锁保证原子性,通过降低锁粒度 (多段锁)增加并发性能。(这一点是java8做出的改进)从而避免synchronized的高开销,执行效率大为提升。
- 如何保证原子性的?自旋+CAS(乐观锁),在该过程中通过compareAndSwapInt比较更新value的值,如果更新失败,重新获取旧值,然后更新。(它实现很简单,就是用一个预期值和内存值比较,如果两值相等,就用预期值替换内存值,并返回true或false。)
- 通过CAS乐观锁保证原子性
- 通过自旋保证当次修改的最终修改成功
- 通过**降低锁粒度(多段锁)**增加并发性能
- 在jdk中CAS是Unsafe类中的api来实现的。
优缺点
- 加锁会影响效率,可以考虑使用院子操作类
- CAS相对于其他锁,不会进行内核态操作,有着一些性能的提升。但同时引入自旋,当锁竞争较大的时候,自旋次数会增多。cpu资源会消耗很高。换句话说,CAS+自旋适合使用在低并发有同步数据的应用场景。
2.说一下 synchronized 底层实现原理?
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 翻译为中文的意思是同步,也称之为同步锁。
synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
synchronized的底层实现是完全依赖JVM虚拟机的,所以谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。
- 对象结构:HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
synchronized的锁升级
锁升级的目的:锁解决了数据安全性,但同样带来了性能的下降,大部分情况下,加锁的代码不仅不存在多线程竞争,而且总由同一个线程多次获得。所以基于这样一个概率,synchronized 在JDK1.6 后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁
- 无锁:没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只会有一个线程能修改成功
- 偏向锁:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。
- **轻量级锁:**它不是用来替换重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
- **重量级锁:**指的是原始的Synchronized的实现,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。
锁升级的原理或者说过程:
-
锁对象的对象头里有一字段: threadid 。
-
首次访问锁对象, threadid 为空,JVM 让其持有偏向锁,并将 threadid 设置为当前线程 id;
-
再次访问锁对象,会先判断 threadid 是否与当前线程 id 一致,若一致则可直接使用此对象,若不一致,则升级偏向锁为轻量级锁;
-
等待锁对象中,通过自旋循环一定次数来获取锁,执行一定次数后,若还未能正常获取到要使用的对象,此时就会把锁从轻量级锁升级为重量级锁。
锁的升级不可逆:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,且锁升级的顺序是不可逆的。
五、线程安全
指某个函数或者函数库在多线程环境中被调用时,能正确的处理多个线程中的共享变量,使程序正确执行。
Java中线程安全体现在以下三个方面:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作
- 可见性:一个线程对主内存的修改可以及时地被其他线程看到
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序
因此,只要一段满足上述三个条件,我们就可以说该代码是线程安全的。
Java中提供了如下解决方案:
- 使用线程安全类,如:java.util.concurrent包下的类
- 使用sychronized关键字
- 使用并发包下Lock相关锁
六、死锁
死锁,是指多个线程同时被阻塞,其中一个或者全部线程都在等待某个资源,由于资源争夺而造成的一中僵局。若无外力推进,他们都将无法推进。由于无限期的阻塞,程序没有办法进行正常终止。
1.死锁产生的必要条件
- 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用。
- 不可剥夺,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
- 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了环路等待。
当上述四个条件都成立的时候,就会形成死锁。只要打破上述的条件之一,就不会形成死锁
2.产生的原因
(1)资源竞争
当多个进程共享资源,如果资源数目小于进程数,形成资源竞争,造成死锁
(2)竞争不可剥夺资源
如果系统分配的不可剥夺资源数目小于进程所需,就会造成资源竞争,形成僵局。
- 可剥夺资源:当一个进程获得资源后,该资源可被其他进程或者系统剥夺。比如,优先级高的进程剥夺优先级低的进程的资源。
- 不可剥夺资源:当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
(3)竞争临时资源
临时资源指的就是进程短暂的用过,之后就不再使用了。比如硬件中断、信号、消息、缓冲区内的消息等,它也可能引起死锁。
(4)进程推进顺序不合法
比如,进程P1 P2此时各自获得资源A B,但是,P1需要得到B后再释放资源,P2需要得到A后再释放资源,此时系统不安全。如果再进行推进,就会死锁
3.避免死锁
在资源分配时,使用某种方法避免系统进入不安全的状态。银行家算法
- 安全状态:如果存在一个由系统中所有进程构成的安全序列P1,…,Pn,则系统处于安全状态。安全状态一定是没有死锁发生。
- 不安全状态:不存在一个安全序列,不安全状态不一定导致死锁。
七、几组辨析
1.synchronized 和 volatile 的区别是什么?
- volatile 是变量修饰符;synchronized 是修饰类、方法、代码块。
- volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
2.synchronized 和 Lock 区别是什么?
- synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
- synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
3.synchronized 和 ReentrantLock 区别是什么?
synchronized 早期的实现比较低效,对比 ReentrantLock,大多数场景性能都相差较大,但是在 Java 6 中对 synchronized 进行了非常多的改进。
- ReentrantLock 使用起来比较灵活,但是必须有释放锁的配合动作;
- ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
- ReentrantLock 只适用于代码块锁,而 synchronized 可用于修饰类、方法、代码块等。
八、线程池
线程池是一种管理线程的技术,它可以帮助程序员更有效地管理多线程应用程序。它可以提高程序的性能,减少系统资源的消耗,并有助于程序的可维护性。线程池可以创建一组可重用的线程,这些线程可以处理任务,而不必每次都创建新线程。线程池可以让程序员更容易地管理线程,并且可以更有效地利用系统资源。
为什么要使用线程池?
- 线程复用,降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 控制最大并发数,提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。
1、线程池的核心概念
- int corePoolSize 核心线程数
- int maximumPoolSize 最大线程数(临时线程数 = 最大线程数 - 核心线程数)
- long keepAliveTime 核心线程数满了之后创建的最大线程多久后释放
- TimeUnit unit 释放时间的单位,m,h,d
- BlockingQueue workQueue 阻塞消息队列
- ThreadFactory threadFactory 线程工厂
- RejectedExecutionHandler handler 拒绝策略
核心线程数
- 当线程是IO密集型时,主要消耗磁盘的读写性能,可以设置为2*n,n为当前服务器核数(比如8核16G的服务器设置为16,Runtime.getRuntime().availableProcessors()获取)
- 当线程是CPU密集型时,主要消耗CPU性能,设置为n+1
最大线程数
- 当核心线程核消息队列都满了之后才会去创建最大线程,直到达到最大线程数,之后的线程就会执行拒绝策略
阻塞消息队列
- ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小,读写用一把锁,性能较差;
- LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;一般是用这个,指定了大小则限制具体大小,写核读分两把锁进行操作,所以性能较好
- synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。
注意:当核心线程数满了之后,新线程会先存储在消息队列中,当消息队列也满了之后才会去创建最大线程,直到达到最大线程数,之后的线程就会执行拒绝策略
线程工厂
- 创建线程的类,可以用默认工厂,也可以自定义线程工厂实现 implements ThreadFactory类,实现newThread方法,自定义工厂的话可以设置线程名或者定义辅助线程
拒绝策略
- ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
- ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
- ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
- ThreadPoolExecutor.CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务
- 自定义拒绝策略
2、线程池的工作原理
当线程池中有任务需要执行时,线程池会判断如果线程数量没有超过核心数量就会新建线程池进行任务执行,如果线程池中的线程数量已经超过核心线程数,这时候任务就会被放入任务队列中排队等待执行;如果任务队列超过最大队列数,并且线程池没有达到最大线程数,就会新建线程来执行任务;如果超过了最大线程数,就会执行拒绝执行策略。
3、线程池的使用
在JDK中rt.jar包下JUC(java.util.concurrent)创建线程池有两种方式:ThreadPoolExecutor 和 Executors,其中 Executors又可以创建 6 种不同的线程池类型,但实际不推荐使用该种方法。
1.ThreadPoolExecutor有哪些常用的方法?
- submit()/execute():执行线程池
- shutdown()/shutdownNow():终止线程池
- isShutdown():判断线程是否终止
- getActiveCount():正在运行的线程数
- getCorePoolSize():获取核心线程数
- getMaximumPoolSize():获取最大线程数
- getQueue():获取线程池中的任务队列
- allowCoreThreadTimeOut(boolean):设置空闲时是否回收核心线程
2.说说submit()和 execute()两个方法有什么区别?
submit() 和 execute() 都是用来执行线程池的,只不过使用 execute() 执行线程池不能有返回方法(只能执行Runnable类型的任务),而使用 submit() 可以使用 Future 接收线程池执行的返回值(可以执行Runnable和Callable类型的任务)。
3. 线程池为什么需要使用(阻塞)队列
阻塞队列是一种数据结构,用来存储任务,由线程池来控制对阻塞队列的操作(插入、弹出等)。
阻塞队列主要是:有限的队列长度,队列满了,可以阻塞保留当前任务;队列为空,阻塞线程,保持核心线程不退出。
-
阻塞队列通过阻塞可以保留住当前想要继续入队的任务。
-
阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程(当没有任务时,阻塞核心线程)进入wait状态,释放cpu资源。
-
阻塞队列自带阻塞和唤醒的功能,不需要额外处理,无任务执行时,线程池利用阻塞队列的take方法挂
起,从而维持核心线程的存活、不至于一直占用cpu资源。
4.线程池为什么要使用阻塞队列而不使用非阻塞队列?
阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。当队列中有任务时才唤醒对应线程从队列中取出消息进行执行。使得在线程不至于一直占用cpu资源。
不用阻塞队列也是可以的,不过实现起来比较麻烦。
4、线程池新建线程的策略是什么?
- 如果线程数 < 核心线程数,立即创建新的线程用于执行;
- 如果线程数 >= 核心线程数并且线程数 < 最大线程数,队列未满,则加入等待队列,不创建新线程;
- 如果线程数 >= 核心线程数并且线程数 < 最大线程数,队列已满,则创建新线程;