Java多线程与并发之面试常问题

JAVA多线程与并发


进程与线程的区别

进程是资源分配的最小单位,线程是CPU调度的最小单位

  • 所有与进程相关的资源,都被记录在PCB(进程控制块)中
  • 进程是抢占处理机的调度单位;线程属于某个进程,共享其资源
  • 线程只由堆栈寄存器、程序计数器和TCB(线程控制块)组成

总结:

  • 线程不能看做独立应用,而进程可看做独立应用
  • 进程有独立的地址空间,相互不影响,线程只是进程的不同执行路径
  • 线程没有独立的地址空间,多进程的程序比多线程的程序健壮
  • 进程的开销比线程大,切换代价高

Java进程和线程的关系

  • Java对操作系统的功能进行封装,包括进程和线程
  • 运行一个程序会产生一个进程,进程包含至少一个线程
  • 每个进程对应一个JVM实例,多个线程共享JVM里的堆
  • Java采用单线程编程模型,程序会自动创建主线程
  • 主线程可以创建子线程,原则上要后于子线程完成执行

start和run的区别

  • 调用start()方法会创建一个新的子线程并启动
  • run()方法只是Thread的一个普通方法的调用(注:还是在主线程里面执行)

Thread和Runnable

  • Thread是实现了Runnable接口的类,使得run支持多线程
    public class Thread implements Runnable
  • 因为类的单一继承原则,推荐多使用Runnable接口

如何给run()方法传参

实现方式有三种

  • 构造函数传参
  • 成员变量传参
  • 回调函数传参

如何实现处理线程的返回值

实现的方式主要有三种:

  • 主线程等待法
  • 使用Thread类的join()阻塞当前线程以等待子线程处理完毕
  • 通过Callable接口实现:通过Future Or 线程池获取

Java线程的六个状态

  • 新建(New):创建后尚未启动的线程的状态
  • 运行(Runnable):包含Running 和Ready
  • 无限期等待(Waiting):不会被分配CPU执行时间,需要显示被唤醒
  • 限期等待(Timed Waiting):在一定时间后会由系统自动唤醒
  • 阻塞(Blocked):等待获取排它锁
  • 结束(Terminated):已终止线程的状态,线程已经结束执行

Sleep和wait的区别

  • sleep是Thread的方法,wait是Object类中定义的方法
  • sleep()方法可以在任何地方使用
  • wait()方法只能在synchronized方法或synchronized块中使用
  • Thread.sleep只会让出CPU,不会导致锁行为的改变
  • Object.wait不仅让出CPU,还会释放已经占有的同步资源锁

notify和notifyAll的区别

锁池EntryList:假设线程A已经获得了某个对象(不是类)的锁,而其他线程B,C想要调用这个对象的某个synchronized方法(或者块),由于B,C线程在进入对象的synchronized方法之前必须获得该对象锁的拥有权,而恰巧该对象的锁刚好被线程A所占用,此时B,C线程就会被阻塞,进入一个地方去等待锁的释放,这个地方就是锁池。

等待池WaitSet:假设线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁,同时线程A就进入到了该对象的等待池中,进入到等待池中的线程不会去竞争该对象的锁。

  • notifyAll会让所有出于等待池WaitSet的线程全部进入锁池EntryList去竞争获取锁的机会
  • notify只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会

Yield与join的区别

当调用Thread.yeild()函数时,会给线程调度器一个当前线程愿意让出CPU使用的暗示,但是线程调度器可能会忽略这个暗示。并不会让出当前线程的锁。

  • yield是一个静态的原生(native)方法
  • yield不能保证是的当前正在运行的线程迅速转换到可运行的状态,仅能从运行态转换到可运行态,而不能是等待或阻塞。

join方法可以使得一个线程在另一个线程结束后再执行。当前线程将阻塞直到这个线程实例完成了再执行。

  • join方法可设置超时,使得join()方法的影响在特定超时后无效,如,join(50)。注:join(0),并不是等待0秒,而是等待无限时间,等价join()。
  • join方法必须在线程start()方法调用之后才有意义
  • join方法的原理,就是调用了相应线程的wait方法

如何中断线程

已经被抛弃的方法:

  • 通过调用stop()方法停止线层(原因:不安全,会释放掉锁)
  • 通过调用suspend()和resume()方法

目前使用的方法:

  • 调用interrput(),通知线程应该中断了:1.如果线程出于被阻塞的状态,那么线程将立即退出被阻塞状态,并抛出一个InterruputedException异常。2.如果线程出于正常的活动状态,那么会将该线程的中断标志设置为true。被设置中断标志的线程将继续正常运行,不受影响。
  • 需要被调用的线程配合中断:1.在正常运行任务时,经常检查本线程的中断标志位,如果被设置了中断标志就自行停止线程。2.如果线程处于正常活动状态,那么会将该线程的中断标志设置为true。被设置中断标志的线程将继续正常运行,不受影响。

Synchronized

线程安全出现的原因:

  • 存在共享数据(也称为临界资源)
  • 存在多线程共同操作这些共享数据

解决线程安全的根本办法:同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据之后再对共享数据进行操作,引入了互斥锁

互斥锁的特性:

  • 互斥性:即在同一个时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块(复合操作)进行访问。互斥性也成为操作的原子性。
  • 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新的共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起不一致性。
  • synchronized锁的不是代码,是对象。

根据获取的锁分类:

  • 获取对象锁:1、同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号()中的实例对象。2、同步非静态方法(synchronized method),锁是当前对象的实例对象。
  • 获取类锁:1、同步代码块(synchronized(类.class)),锁是小括号()中的类对象(class对象)。2、同步静态方法(synchronized static method),锁是当前对象的类对象(class对象)。
  • 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块
  • 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个线程访问对象的同步方法,会被阻塞
  • 类锁和对象锁互补干扰

synchronized底层实现原理:

  • Monitor:每个java对象天生自带了一把看不见的锁(c++实现)
  • Monitor锁的竞争、获取与释放
  • 自旋锁:缘由,1、许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得。2、通过让线程执行忙循环等待锁的释放,不让出CPU。3、若锁被其他线程长时间占用,会带来许多性能上的开销
  • 自适应自旋锁:1、自旋的次数不固定。2、由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
  • 锁消除:JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁
  • 锁粗化:通过扩大锁的范围,避免反复的加锁和解锁

锁的内存语义:

  • 当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中去;
  • 而当线程获得锁时,Java内存模型会把该线程对应的本地内存置为无效,从而使得监视器保护的临界区代码必须从主内存中读取共享变量。

synchronized的四种状态:

  • 无锁
  • 偏向锁:减少同一线程获取锁的代价,大多数情况下,锁不存在多线程竞争,总是由同一线程多层次获得。核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时MarkWord的结构也变为偏向锁结构,当该线程再次请求锁时,无需任何同步操作,即获取锁的过程只需要检查Markword的锁标记位为偏向锁以及当前线程Id等于Markword的ThreadId 即可,这样就省去了大量有关锁申请的操作。不适合锁竞争比较激烈的多线程场合
  • 轻量级锁:由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。适应场景:线程交替执行同步代码块。若存在同一时间访问同一锁的情况,就导致轻量级锁膨胀为重量级锁
  • 重量级锁

AQS:

AQS提供了一种实现阻塞锁和一系列依赖FIFO等待队列的同步器的框架,AbstractQueuedSynchronizer中对state的操作是原子的,且不能被继承。所有的同步机制的实现均依赖于对改变量的原子操作。为了实现不同的同步机制,我们需要创建一个非共有的(non-public internal)扩展了AQS类的内部辅助类来实现相应的同步逻辑,AbstractQueuedSynchronizer并不实现任何同步接口,它提供了一些可以被具体实现类直接调用的一些原子操作方法来重写相应的同步逻辑。AQS同时提供了互斥模式(exclusive)和共享模式(shared)两种不同的同步逻辑。

ReentrantLock:

  • jdk1.5后引入了ReentrantLock(再入锁),位于java.util.concurrent.locks包
  • 和CountDownLatch、FutrueTask、Semaphore一样基于AQS实现
  • 能够实现比synchronized更细粒度的控制,如控制fairness
  • 调用lock()之后,必须调用unlock()释放锁
  • 性能未必比synchronized高,并且也是可重入的
  • ReentrantLock公平性的设置:参数为true时,1、倾向于将锁赋予等待时间最久的线程。2、公平锁:获取锁的顺序按先后调用lock方法的顺序。3、synchronized并不是公平性锁

synchronized和ReentrantLock的区别:

  • synchronized是关键字,ReentrantLock是类
  • ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
  • ReentrantLock可以获取各种锁的信息
  • ReentrantLock可以灵活的实现多路通知

synchronized和volatile的区别

  • volatile本质是告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞直到该线程完成变量操作为止
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量修改的可见性和原子性
  • volatile不会造成现成的阻塞;synchronized可能造成线程的阻塞
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可被编译器优化

线程间的通讯方式

本质上有两大类:共享内存机制和消息通信机制。

  • 同步:多个线程通过synchronized关键字这种方式来实现线程间的通信。如:线程A需要等待线程B执行完method方法后,线程A才能执行这个方法,以此实现线程A,B之间的通讯。
  • while轮询的方式(不建议使用):线程A不断地改变条件,线程B不停地通过while语句检测某个条件(这个条件与线程A的操作有关)是否成立 ,从而实现了线程间的通信。缺点:浪费资源,线程B会不停的while
  • wait/notify机制:线程A需要线程B完成某任务在执行时,线程A调用wait()方法,进入等待池中,等待线程B的唤醒。线程B完成某任务后(这个任务线程A所需要的),调用notify将其唤醒。优点:比起while轮询方法,更加的节约资源。缺点:通知过早,会打乱程序的执行逻辑。即线程B先于线程A占用CPU,但是此时线程A并为执行
  • 管道通信:就是使用java.io.PipedInputStream 和 java.io.PipedOutputStream进行通信

Java内存模型

java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实的存在,它描述一组规范或者规则,通过这组规范定义了程序中各个变量的访问方式。

JMM中的主内存:

  • 存储Java实例对象
  • 包括成员变量、类信息、常量、静态变量等
  • 数据共享的区域,多线程并发操作时会引发一系列的安全问题

JMM中的工作内存:

  • 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
  • 字节码行号指示器、Native方法信息
  • 属于线程私有数据区域,不存在线层安全问题

JMM与java内存区域划分是不同的概念层次:

  • JMM描述的是一组规则,围绕原子性,有序性,可见性展开
  • 相似点:存在共享区域和私有区域

JMM如何解决可见性问题:

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序
  • 无法通过happens-before原则推导出来的,才能进行指令重排序
  • 如果操作A happens-before 操作B,那么操作A在内存上所做的操作对操作B来说都是可见的

happens-before

  • 程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个读操作
  • 传递规则:如果操作A先行发生于操作B,操作B先行发生于操作C,则A先行发生于C
  • 线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程中断原则:对线程interrupt()方法调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结原则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结原则:一个对象的初始化完成先行发生于他的finalize()方法的开始

CAS

compare and swap:

  • 包含三个操作数,内存位置(V),预期原值(A)和新值(B)
  • J.U.C的atomic包提供了常用的原子性数据类型以及引用、数组等相关类型和更新操作工具,是很多线程安全程序的首选
  • Unsafe类虽然提供CAS服务,但因能够操纵任意内存地址读写而有隐患
  • Java9以后,可以使用Variable Handle API 来代替Unsafe
  • 缺点:若循环时间长,则开销很大,只能保证一个共享变量的原子操作,ABA问题(解决:通过版本来解决ABA问题,AtomicStampedReference)

JAVA线程池

为什么要使用线程池

  • 降低资源消耗
  • 提高线程的可管理性
  • 提高响应速度;

J.U.C的三个Executor接口

  • Executor:运行新任务的简单接口,将任务提交和任务执行细节解耦
  • ExecutorService:具备管理执行器和任务生命周期的方法,提交任务机制更完善
  • ScheduledExecutorService:支持Future和定期执行任务

线程池的状态:

  • RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务
  • SHUTDOWN;不再接受新提交的任务,但可以处理存量任务
  • STOP:不再接受新提交的任务,也不处理存量任务
  • TIDYING:所有的任务都已经终止
  • TERMINATED:teriminated()方法执行完后进入该状态

线程池的大小如何选定:

  • CPU密集型:线程数=按照核数或者核数+1 设定
  • I/O密集型:线程数 = CPU核数 *(1+平均等待时间/平均工作时间)

线程池的参数

  • corePoolSize:线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。
  • maximumPoolSize:线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;
  • keepAliveTime:线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用;
  • unit:keepAliveTime的单位;
  • workQueue:用来保存等待被执行的任务的阻塞队列,且任务必须实现Runable接口,在JDK中提供了如下阻塞队列:
    1、ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
    2、LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
    3、SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
    4、priorityBlockingQuene:具有优先级的无界阻塞队列;
  • threadFactory:创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。
  • handler:线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
    1、AbortPolicy:直接抛出异常,默认策略;
    2、CallerRunsPolicy:用调用者所在的线程来执行任务;
    3、DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    4、DiscardPolicy:直接丢弃任务;
    当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。
  • Exectors:工厂类提供了线程池的初始化接口
    • newFixedThreadPool(int nThreads) 指定工作线程数量的线程池
    • newCachedThreadPool()处理大量短时间工作任务的线程池。1:试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;2:如果线程闲置的时间超过阈值,则会被终止并移除缓存;3、系统长时间闲置的时候,不会消耗什么资源
    • newSingleThreadExcutor()创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代它
    • newSingleThreadScheduledExecutor()与newScheduledThreadPool(int corePoolSize)定时或者周期性的工作制度,两者的区别在於单一工作线程还是多个线程
    • newWorkStealingPool()内部会构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序

线程池的任务提交:线程池框架提供了两种方式提交任务,根据不同的业务需求选择不同的方式。

  • Executor.execute():通过Executor.execute()方法提交的任务,必须实现Runnable接口,该方式提交的任务不能获取返回值,因此无法判断任务是否执行成功。
  • ExecutorService.submit():通过ExecutorService.submit()方法提交的任务,可以获取任务执行完的返回值。

线程池任务的执行:具体的执行流程如下:

  • 1、workerCountOf方法根据ctl的低29位,得到线程池的当前线程数,如果线程数小于corePoolSize,则执行addWorker方法创建新的线程执行任务;否则执行步骤(2);
  • 2、如果线程池处于RUNNING状态,且把提交的任务成功放入阻塞队列中,则执行步骤(3),否则执行步骤(4);
  • 3、再次检查线程池的状态,如果线程池没有RUNNING,且成功从阻塞队列中删除任务,则执行reject方法处理任务;
  • 4、执行addWorker方法创建新的线程执行任务,如果addWoker执行失败,则执行reject方法处理任务;

addWoker方法实现的前半部分:

1、判断线程池的状态,如果线程池的状态值大于或等SHUTDOWN,则不处理提交的任务,直接返回;

2、通过参数core判断当前需要创建的线程是否为核心线程,如果core为true,且当前线程数小于corePoolSize,则跳出循环,开始创建新的线程,具体实现如下:

线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程,其中Worker类设计如下:

  • 1、继承了AQS类,可以方便的实现工作线程的中止操作;
  • 2、实现了Runnable接口,可以将自身作为一个任务在工作线程中执行;
  • 3、当前提交的任务firstTask作为参数传入Worker的构造方法;

runWorker方法是线程池的核心:

  • 1、线程启动之后,通过unlock方法释放锁,设置AQS的state为0,表示运行中断;
  • 2、获取第一个任务firstTask,执行任务的run方法,不过在执行任务之前,会进行加锁操作,任务执行完会释放锁;
  • 3、在执行任务的前后,可以根据业务场景自定义beforeExecute和afterExecute方法;
  • 4、firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;

getTask实现:

  • 1、workQueue.take:如果阻塞队列为空,当前线程会被挂起等待;当队列中有任务加入时,线程被唤醒,take方法返回任务,并执行;
  • 2、workQueue.poll:如果在keepAliveTime时间内,阻塞队列还是没有任务,则返回null;
    所以,线程池中实现的线程可以一直执行由用户提交的任务。

Future和Callable实现:在实际业务场景中,Future和Callable基本是成对出现的,Callable负责产生结果,Future负责获取结果。

  • 1、Callable接口类似于Runnable,只是Runnable没有返回值。
  • 2、Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果;
  • 3、Future.get方法会导致主线程阻塞,直到Callable任务执行完成;

协程

协程(Coroutine)这个词其实有很多叫法,比如有的人喜欢称为纤程(Fiber),或者绿色线程(GreenThread)。其实究其本质,对于协程最直观的解释是线程的线程。虽然读上去有点拗口,但本质上就是这样。

协程的核心在于调度那块由他来负责解决,遇到阻塞操作,立刻放弃掉,并且记录当前栈上的数据,阻塞完后立刻再找一个线程恢复栈并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别,这整个流程可以称为coroutine,而跑在由coroutine负责调度的线程称为Fiber。

早期,在JVM上实现协程一般会使用kilim,不过这个工具已经很久不更新了,现在常用的工具是Quasar。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章