并发编程常见面试题(超详细)

文章目录

  • 并发编程
    • 进程和线程的区别
    • 并发和并行的区别
    • 创建线程的方式
    • 线程之间的状态,状态之间的转换
    • 新建三个线程,如何保证按顺序执行
    • wait方法和sleep的区别
    • 如何停止一个正在运行的线程
    • synchronized关键字底层原理
    • Monitor属于重量级锁,了解过锁升级吗
    • JMMJava内存模型
    • CAS(Compare And Swap)自旋锁
    • 乐观锁和悲观锁的区别
    • volatile关键字
    • AQS(AbstractQueuedSynchronizer)
    • ReentrantLock实现原理
    • synchronized和Lock的区别
    • 死锁产生的条件
    • Java并发程序出现问题的原因以及解决方法
    • 为什么使用线程池
    • 线程池的核心参数以及执行原理
    • 线程池中常见的阻塞队列

并发编程

进程和线程的区别

  • 进程就是运行一个程序,程序是由指令和数据组成,程序要运行,就需要将指令加载到CPU中,数据加载到内存中,进程就是将指令加载到CPU中,并且将数据加载到内存中,并且指令运行期间还会用到磁盘、网络等设备。线程的话就是一个指令流,线程的运行就是将指令流中的一条条指令交给CPU执行。而进程就是由一个个线程构成,每一个线程执行不同的功能。
  • 不同的进程使用不同的内存空间,同一个进程中的线程共享内存空间。
  • 线程更轻量级,线程间的上下文切换(一个线程切换到另一个线程)成本要比进程上下文切换成本低。

并发和并行的区别

  • 并发就是单核CPU加载线程的过程中是一个线程执行结束然后去执行另一个线程,但是由于速度非常快感觉像是同时进行,微观串行,宏观并行,这种线程轮流使用CPU的做法就称为并发(concurrent)。
  • 并行就是多核CPU在加载线程的过程中,每个核心都可以同时去加载线程,这种同一时间去执行多个线程的方式称为并行。
  • 举个例子,一个保姆需要做饭、打扫卫生、接孩子,她一个人轮流交替做这些,就是并发;这时候又雇了一个保姆,两个人同时完成这些事情,这就是并行。

创建线程的方式

  • 创建线程主要有四种方式,第一种是集成Thread类,然后重写run方法,然后使用start进行启动。第二种是实现Runnable接口,重写run方法,创建这个类的对象,然后创建Thread时将这个对象传入,使用start启动线程。第三种是实现Callable接口,重写call方法(用于返回最后执行线程拿到的结果),需要传入一个泛型,然后创建这个类的对象,这个时候就要创建一个FutureTask类将这个对象传入,然后创建Thread时将创建的FutureTask传入,使用start启动线程。第四种直接使用线程池创建线程,实现Runnable接口,重写run方法,然后创建线程池对象,使用submit将创建的线程提交。
  • Runnable和Callable两者的主要区别是,Runnable的run方法没有返回值,Callable的call方法有返回值,并且是泛型,可以和Future、FutureTask配合获取异步执行结果。还有就是Callable接口的call方法允许抛出异常,而Runnable的run方法异常只能内部消化,不能向上抛出。
  • .run.start的区别,run是正常的执行run方法,可以执行多次,而start是用来启动线程,一个线程只能启动一次,start方法只能调用一次。

线程之间的状态,状态之间的转换

  • 线程主要有六种状态,新建(new),可运行(runnable),阻塞(blocked),等待(waiting),时间等待(timed_waiting),终止(terminated)
  • 当创建一个线程对象的时候是新建状态,当调用start之后就会变为可执行状态,在可执行状态下如果获取到CPU资源就立即执行,并且线程会终止,如果在可执行状态中没有获取到CPU权限时可能会切换到其他状态,比如如果没有获取到锁,就会进入阻塞状态,如果获得锁就会切换到可运行状态,如果线程调用了wait方法就会进入等待状态,只有当其他线程调用notify唤醒后就会切换为可执行状态,如果线程调用了sleep方法,线程就会进入时间等待状态,到时间之后自动切换为可执行状态。

新建三个线程,如何保证按顺序执行

  • 可以使用线程中的join方法解决,阻塞调用此方法的线程进入timed_waiting,比如t.join()直到被调用的线程t执行完成,当前线程才进入runnable状态。
  • 三个线程t1,t2,t3想要按顺序执行,这时候就需要在t2线程中使用t1.join(),在t3线程中使用t2.join();

wait方法和sleep的区别

  • 两者的相同点是都可以让当前线程放弃CPU的使用权,进入阻塞状态。
  • 不同点首先是方法的归属不同,sleep(long)是Thread中的静态方法,wait和wait(long)是Object中的成员方法,每个对象中都会有。
  • 其次醒来的时机不同,两者进入runnable的时机不同,sleep(long)、wait(long)这两个都是在等待longms之后就自动进入runnable状态,wait(long)和wait这两者可以被notify唤醒直接进入runnable状态,wait如果没有notify唤醒会一直等待下去,他们都可以被打断唤醒。
  • 然后这两者的锁性质不同,wait方法必须配合synchronized锁来使用,当使用wait方法放弃CPU时,相应的锁也会释放,其他线程可以获得该对象锁,而sleep如果在synchronized中执行放弃CPU时,不会释放对象锁。

如何停止一个正在运行的线程

  • 线程正常运行,然后使用退出标志,线程正常退出,也就是线程执行完run方法之后自动终止。
  • 使用stop方法强行退出终止,但是目前已经弃用。
  • 使用interrupt方法中断线程,如果打断了阻塞状态的线程,那么就会抛出异常,当调用interrupt方法的时候,相当于将isInterrupt的返回值改为了true,如果是打断了正常运行状态的线程,相当于使用了退出标签正常退出了当前的线程。

synchronized关键字底层原理

  • synchronized(对象锁)采用的互斥的方式,使得同一时刻只有一个线程可以持有。
  • synchronized的底层是由monitor实现的,线程获取锁需要使用锁关联monitor。在monitor中有三个属性,owner、entrylist、waitset。其中owner关联的是获得锁的线程,并且只能关联一个线程,entrylist关联的是处于blocked状态的线程,而waitset关联的是出于waiting状态的线程。

Monitor属于重量级锁,了解过锁升级吗

  • Java中synchronized主要有偏向锁、轻量级锁、重量级锁三种形式,对应的是一个线程持有,不同线程交替持有锁,多个线程竞争锁的情况。
  • 在JVM中,一个对象锁在内存区域分为三个部分,对象头(mark word)、实例数据(objectbody)、对齐填充0,markword用于存储锁自身运行时的数据,比如hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程id等。
  • 底层使用的Monitor是重量级锁,重量级锁里面涉及到了用户态和内核态的切换、进程的上下文转换,成本较高并且性能较低。
  • 线程加锁的时间是错开的(没有竞争),这种可以使用轻量级锁。轻量级锁优化了对象头的锁标志,相比重量级锁的性能提升了很多。但是每次修改都需要CAS操作,用来保证原子性。
  • 一段很长的时间只被一个线程使用锁,就可以使用偏向锁,偏向锁只会在第一次获取锁的时候进行CAS操作,之后线程再去获取锁的时候,只需要判断mark word中的线程id是否是自己的即可,而不是开销大的CAS命令。
  • 当系统调用重量级锁之后,就会将重量级锁指向Monitor这个结构
    并发编程常见面试题(超详细)_第1张图片

JMMJava内存模型

  • JMM是一套规范,定义了多线程程序在执行的时候,内存中的各个变量、对象以及执行顺序等行为。
  • JMM将内存分为两块,一块是线程私有的工作区域(工作内存),一块是所有线程共享的区域(主内存)。
  • 线程和线程之间是相互隔离的,线程和线程的交互需要通过主内存。

并发编程常见面试题(超详细)_第2张图片

CAS(Compare And Swap)自旋锁

while(true){
	int 旧值 = 共享变量值;
	int 结果 = 旧值 ++;
	if(compareAndSwap(旧值, 结果)){
		break;
	}
}
  • CAS的一个思想就是当一个线程需要操作共享内存中的数据的时候需要先判断修改前的数据与共享内存中的数据是否一致,如果不一致则重新获取共享内存中的数据,然后进行操作,然后再判断是否一致,一致的时候可以进行替换操作。
  • CAS体现的是一种乐观锁的思想,在无锁状态下保证线程的原子性。
  • 在操作共享变量的时候使用CAS,效率上更高。
  • CAS底层调用的Unsafe类中的方法,是操作系统提供的,是由其他语言实现的。

乐观锁和悲观锁的区别

  • 乐观锁就是乐观的认为并发操作不加锁的方式实现是没有任何问题的,每次操作前进行判断(CAS)是否成立,不加锁的实现。
  • 悲观锁认为对同一个数据进行并发操作一定是会发生修改的,即使没有修改也会认为修改过,认为并发操作一定是有问题的,必须加锁,synchronized是基于悲观锁的思想的。

volatile关键字

  • 当使用volatile修饰一个共享变量(成员变量,静态成员变量),这个共享变量就具备了两层含义,保证进程之间的可见性、禁止进行指令重排序
  • 使用volatile修饰一个共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。比如现在有一个程序,其中有一个boolean类型的共享变量,线程a修改了这个boolean类型的值,但是另一个线程b在while循环中使用boolean进行循环判断,此时由于JVM中的JIT编译器对这个while循环进行了优化,将while(!flag)变为了while(true),此时就算修改了这个flag,线程b同样还在执行循环,这种情况下有两种解决方式,第一种就是在程序运行时候加入vm参数-Xint,禁用掉JIT,但是会将其他程序的也禁用掉,不推荐;第二种方式就是在修饰这个flag共享变量时候加上volatile关键字,也就是告诉JIT,不要对voletile修饰的变量做优化。
  • 禁止指令重排序的原理是使用了内存屏障,在同一线程中,不同变量之间的读写顺序可能会不一致,这时候修饰属性的时候添加volatile关键字就可以为其添加一层内存屏障,添加内存屏障后,这个变量在进行写操作的时候其他在它之前的变量不能在其之后进行写操作,在进行读操作的时候其它之后的变量不能在其之前进行读操作。

AQS(AbstractQueuedSynchronizer)

  • AQS即抽象队列同步器,是JUC中提供的一种锁机制,它是构建锁或者其他同步组件的基础框架。JUC中很多关于锁的类底层都是基于AQS来实现的。
  • synchronized和AQS的区别,synchronized是关键字底层是C++实现,AQS是Java语言实现的;两者都是悲观锁,synchronized锁是自动创建和释放,AQS是手动创建和释放;synchronized在锁竞争激烈的时候会自动升级为重量级锁,性能较差,AQS在锁竞争激烈的时候会有多种解决方式。
  • 在AQS内部维护了一个state变量,有0和1两种状态,当没有线程访问时是0,当有线程访问这个锁的时候,会将这个state变为1,这时候如果有其他线程想要访问,此时因为state为1,所以访问失败,在AQS内部还维护了一个队列,此时这个访问失败的线程就会进入队列中等待锁释放,后面的线程同理,当持有锁的线程执行完成的时候,state变为0,同时会唤醒队列中的头元素,让它去持有锁。
  • 如果在唤醒头元素的过程中来了两个线程进行抢占,这时候使用CAS来保证state的原子性,当有一个线程抢占到锁,然后将其state变为1,其他线程就会进入队列。
  • AQS是非公平锁,因为当唤醒头元素的过程中来了个线程进行抢占,这时候如果抢占成功是对队列中其他线程是不公平的。只有当新来的线程直接进入队列进行等待,才是公平锁。

ReentrantLock实现原理

  • ReentrantLock是支持可重入锁,也就是调用lock方法后还可以继续调用,不会阻塞。
  • ReentrantLock主要利用CAS+AQS队列来实现的。在创建ReentrantLock锁的时候构造方法的内部会创建一个默认的NonfairSync而这个NonfairSync底层继承的就是AQS,在NonfairSync中也有一个state用来记录线程锁是否被占用,里面还维护了一个双向队列,头节点head和尾节点tail,还有一个exclusiveOwnerThread属性,用于指向获取锁成功的线程。
  • 支持公平锁和非公平锁。在创建ReentrantLock的时候默认的构造器是非公平锁,但是我们可以通过传参(true or false)构造器创建公平锁。但是公平锁的效率往往是没有非公平锁高的。

synchronized和Lock的区别

  • synchronized是关键字,底层是基于JVM实现的,Lock是接口,是Java中的JDK提供的。在使用synchronized的时候会自动释放锁,而使用Lock时需要手动调用unlock方法释放锁。
  • 两者都是属于悲观锁,都具备基本的互斥、同步、可重入功能。Lock提供了多种synchronized不具备的功能,比如公平锁、可打断(lockInterruptibly)、可超时(tryLock)、多条件变量(Condition)。
  • 在性能上,在锁竞争不是很激烈的时候,synchronized做了非常多的优化,像是偏向锁、轻量级锁等;但是当锁竞争激烈的时候synchronized就会转为重量级锁,性能大幅度下降,而Lock通常会提供更好的性能实现。

死锁产生的条件

  • 当一个线程在执行过程中需要同时获取多把锁,这时候容易发生死锁。比如线程1持有a锁等待获取b锁,线程2持有b锁等待获取a锁。
  • 如果出现了死锁,我们可以通过idea下的Terminal,通过jps和jstack来进行诊断,使用jps输出JVM中运行的进程状态信息,使用jstack查看Java进程内线程的堆栈信息、查看日志,并检查线程中是否有死锁。还可以通过JDK下的可视化工具jconsole、VisualVM来检查死锁问题。

Java并发程序出现问题的原因以及解决方法

  • 原子性问题,原子性就是一个线程在CPU执行过程中要么全部执行完成,要么不执行,不可以中断。并发中出现这种问题可以通过加锁解决,使用synchronized或Lock。
  • 可见性,在一个线程修改共享内存中的数据的时候,另一个线程必须同步这个共享内存中的数据。可以使用加锁解决,但是性能方面不太好,可以加volatile关键字解决。
  • 有序性问题的话可以使用volatile解决,volatile里面有内存屏障,可以方式变量在其他变量之前或之后优先读写。

为什么使用线程池

  • 我们在创建线程的过程中都会占用一定的内存空间,如果无限的创建线程就有可能浪费内存,严重的话会导致内存溢出。
  • 我们CPU一般只有一个,当如果有大量线程进行创建,很多线程没有CPU的执行权,那这些线程都得进行等待,会造成大量线程之间的切换,也会导致性能变慢。

线程池的核心参数以及执行原理

并发编程常见面试题(超详细)_第3张图片

  • 核心线程数目是线程中主要执行任务的数目,第二个是最大线程的数目=核心线程数目+临时线程最大数目,第三个生存时间就是临时线程的生存时间,如果在生存时间内没有新任务,那么此线程资源会释放,第四个时间单位就是生存时间的单位,如秒、毫秒等,第五个阻塞队列,当没有空闲的核心线程时,新来的任务会加入到此队列排队,队列满了会创建临时线程执行任务,第六个线程工厂可以用来定制线程对象的创建,比如设置线程的名字、是否守护线程等,第七个参数拒绝策略,当所有的线程都在繁忙,核心线程和临时线程都在忙,阻塞队列 也满了,就会触发拒绝策略。
  • 线程池的执行原理是,当一个线程创建进入线程池,这个任务会首先提交给核心线程判断是否已满,如果未满则使用核心线程进行执行,如果已满则去判断阻塞队列是否已满,如果阻塞队列未满,则将这个任务添加到阻塞队列的任务队列,如果已满,则去判断当前线程数目是否小于最大线程数目,如果小于最大线程数,则会创建临时线程去执行任务(一般当核心线程和临时线程有一方为空就回去判断阻塞队列中是否为空,不为空就将其中的任务调出执行),如果已满,则会触发拒绝策略进行处理。
  • 拒绝策略的处理一般分为四种,第一种是默认的处理方法返回异常(AbortPolicy);第二种是用调用者所在线程来执行任务(CallerRunsPolicy),也就是使用当前正在执行的线程来执行这个任务(与线程池无关);第三种是丢弃阻塞队列中最靠前的任务(DiscardOldestPolicy),并执行当前任务;第四种是直接丢弃当前任务(DiscardPolicy)。

线程池中常见的阻塞队列

  • 常见的阻塞队列会有四种,基于数组的有界阻塞队列(ArrayBlockingQueue),基于链表的有界阻塞队列(LinkedBlockingQueue),还有一个优先队列(DelayedWorkQueue),可以保证每次出队都是当前队列中时间最靠前的,还有一种是不存储元素的阻塞队列(SynchronousQueue),每个插入操作都必须等待一个移出操作。
  • 最常见的主要是前两种,一种是基于数组有界的阻塞队列,这种在创建的时候底层会创建一个数组,当你传入容量时会设置数组长度,否则会默认设置为Integer.MAX_VALUE,它的底层只有一把锁,出队入队都要使用这把锁,效率较低,另一种是基于链表的有界阻塞队列,底层是单向链表实现的,默认是无界的,也支持有界,当创建节点的时候进行添加数据,然后入队的时候会生成新的Node,它的底层有两把锁,分别在链表的头尾,链表的出队入队互不影响。

你可能感兴趣的:(Java基础,java,面试)