JUC常见面试题总结


JUC常见面试题总结

1.知道synchronized原理吗?

在这里插入图片描述

答:synchronized是java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。

具体地:

  • 执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。
  • 执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。

synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。

从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。

如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。

  • 当多个线程进入同步代码块时,首先进入entryList 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  • 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
  • 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

2.synchronized是可重入锁吗,及其实现原理?

答:是,可重入锁是:同一个线程重复请求由自己持有的锁对象时,可以请求成功而不会发生死锁。
synchronized底层的实现原理是利用计算机系统的mutex Lock实现。每一个可重入锁都会关联一个线程ID和一个锁状态status(计数器)。当一个线程请求方法时,会去检查锁状态,如果锁状态是0,代表该锁没有被占用,直接进行CAS操作获取锁,将线程ID替换成自己的线程ID。如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,如果是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法。如果是非重入锁,就会进入阻塞队列等待。释放锁时,可重入锁,每一次退出方法,就会将status减1,直至status的值为0,最后释放该锁。释放锁时,非可重入锁,线程退出方法,直接就会释放该锁。

3.线程安全本质是什么,Java 如何保证线程安全?

答:线程安全是指在多线程环境中,共享数据的访问和修改不会产生不一致或者不正确的结果。Java提供了多种机制来保证线程安全,其本质是通过控制并发访问共享资源的方式来实现的

Java的线程安全是建立在原子性、可见性和有序性这三个基本特点上的。其中,原子性指的是一个操作不会被中断,要么全部完成,要么全部不完成;可见性指的是多个线程访问同一个共享变量时,一个线程修改了变量,其他线程都可以立即看到这个修改;有序性指的是程序的执行顺序按照代码的先后顺序执行。

Java提供了以下多种机制来保证线程安全:

  • synchronized关键字:通过互斥锁来保证同一时刻只有一个线程访问共享资源,其他线程需要等待锁的释放。synchronized关键字可以修饰方法、代码块或者静态方法。
  • ReentrantLock类:ReentrantLock是可重入锁,与synchronized关键字相似。它提供了更多的灵活性和语义选择。线程获取锁时可以选择不同的等待策略,例如公平锁和非公平锁。
  • volatile关键字:volatile关键字可以保证线程可以立即看到共享变量的更改,而不会看到变量的过期值。volatile关键字适用于单纯的读取数据的操作,不能保证多个操作之间的原子性。
  • AtomicInteger类:AtomicInteger是一个原子类,提供了多个原子性方法,如getAndIncrement()、getAndDecrement()、getAndAdd()等。这些方法是原子性的,不需要使用synchronized关键字来保证线程安全。

通过使用以上这些机制,Java可以有效地保证线程安全。在开发多线程程序时,需要注意数据的访问和修改顺序,避免不正确的并发操作。同时,还应该避免死锁和饥饿等并发问题,以提高程序的性能和健壮性。

4.callable,runnable 有什么区别?

答:Callable和Runnable都是Java中用于实现多线程的接口,它们的最大区别是Callable可以返回一个值,而Runnable则不能。下面是它们的具体特点和区别:

  • 返回值:Callable可以在任务执行完成后返回一个值,而Runnable则无返回值。Callable的返回值是通过Future类来处理的。
  • 异常处理:Callable中的任务执行过程中,如果抛出了异常,异常信息会保存在Future任务中,而在Runnable中,异常需要在线程执行体内手动处理。
  • 泛型:Callable接口是一个泛型接口,可以指定任务的返回值类型,而Runnable是一个无返回值的接口。
  • 执行方式:Callable接口的任务执行可以通过ExecutorService的submit方法异步执行,得到一个Future来控制任务的执行,而Runnable只能通过Thread类的构造方法创建线程对象,再调用start方法来执行。
  • 实现方式:Callable的call方法中需要包含一个返回结果的语句,而Runnable的run方法只是线程执行体。因此,Callable接口的实现相对于Runnable接口的实现更复杂,但是Callable更加灵活。

综上所述,Callable和Runnable虽然都是实现多线程的接口,但是它们的使用场景不同,如果需要获取线程执行的结果或者使用线程池处理多个任务,就可以使用Callable接口来实现;如果只是需要简单地实现多线程,可以使用Runnable接口。

5.线程不正常终止会发生什么?

答:线程是一个独立运行的执行单元,当线程在没有执行完任务的情况下被强制终止,就会发生线程的不正常终止。线程不正常终止可能会导致以下问题:

  • 未释放的资源:当一个线程不正常地终止,可能会导致它正在占用的一些资源未能正确释放,例如文件句柄、数据库连接等,导致系统资源紧张或出现错误。
  • 数据不一致:如果线程在执行过程中遇到异常或错误而被强制终止,有一定概率导致数据不一致,因为线程在终止之前并未来得及执行完所有的必要操作。
  • 线程安全问题:如果线程在执行过程中对共享数据进行了修改,但被强制终止前没有释放锁,那么其他线程就无法正确访问该共享数据,导致线程安全问题。
  • 程序不可预测:线程不正常终止会导致程序的流程出现不可预测的状况,使得程序的稳定性和可靠性受到影响。

因此,为了避免线程的不正常终止,开发人员应该编写健壮的程序,处理可能发生的异常和错误,并及时释放资源和锁。此外,使用线程池可以有效地管理线程的生命周期,避免线程的滥用和不必要的创建和销毁,提高程序的性能和健壮性。

6.线程占用的空间具体有哪些?

答:一个线程在运行时需要占用多种空间,包括寄存器、堆栈、堆内存和线程栈等。

  • 1、CPU寄存器空间:在运行过程中,Java线程会使用CPU寄存器存放一些状态信息,如程序计数器(Program Counter
    Register)等。CPU寄存器是CPU内部的高速缓存,由于读写速度快,线程在访问或修改共享资源时可以提高执行效率。
  • 2、线程栈空间:Java线程启动时会创建一个线程栈,线程栈中保存了线程运行时需要的状态信息,如函数参数、局部变量、返回地址等。线程栈的大小是固定的,如果线程栈中的局部变量过多,可能会导致栈空间不足而发生栈溢出。
  • 3、堆空间:Java的对象由垃圾回收器自动收集,存放在堆内存上。多个线程可以共享堆内存中的对象资源,需要通过同步机制来协调访问,以保证线程安全。

上述都是线程占用的空间,其中,寄存器空间是CPU内部的缓存,栈和堆则是线程在内存中的存储结构。在多线程环境下,为了减少线程之间的竞争,需要合理地分配、使用这些空间,以充分利用CPU资源,并保证线程安全。

7.Java 的线程和 Linux 的线程有什么区别,为什么需要 Java 的线程?

答:Java线程和Linux线程有以下几点区别:

  • 调度:Java线程只由Java虚拟机(JVM)调度,而Linux线程由操作系统调度。
  • 创建和销毁:在Java中创建和销毁线程比在Linux中更加轻量级。
  • 内存开销:在Java中,创建线程的内存开销相比Linux更小。
  • 栈大小:在Java中,每个线程有自己的栈,栈的大小是固定的。在Linux中,每个线程也有自己的栈,但是栈的大小是可调的。

另外,Java的线程库提供了睡眠时间、互斥锁、条件变量等高级抽象,使得多线程编程更加方便。此外,Java线程库内建了ThreadLocal机制,可以方便地实现线程相关的数据操作,而在Linux中,需要手动实现这些操作。

Java作为一种跨平台的编程语言,线程是Java虚拟机(JVM)的一个核心特性,提供了多线程编程的一些高级特性,比如线程池、同步和异步机制等。需要Java的线程主要是因为Java是一种面向对象的跨平台编程语言,它的多线程编程特性可以支持更加复杂的程序结构和算法设计,而Linux的线程虽然性能更强,但是对于跨平台编程来说限制较大,缺乏一些高级的多线程编程特性。因此,需要根据应用场景来选择适合的多线程编程环境。

8.volatile 具体实现原理?

在 Java 中, volatile 是一种用于多线程编程的关键字,它可以确保变量对于所有线程的可见性,并且禁止编译器和处理器对代码进行优化,从而避免出现因指令重排而引起的并发问题。

Java 中使用 volatile 关键字实现多线程的可见性是基于内存屏障和特定的 CPU 指令实现的。内存屏障是一种阻止处理器记忆行为乱序执行的同步设备,它会在处理器执行过程中划分出内存屏障来防止指令重排。当使用 volatile 修饰的变量进行写操作时,会使用以下两种内存屏障操作:

  1. Store Memory Barrier: 保证 Store操作执行之前的所有指令的内存状态已经完成,并将写缓冲区中的数据写入主存中,从而使其他线程可见。
  2. Store-Load Memory Barrier: 保证所有 Store 操作的内存状态都已经刷新到主存中,并保证所有 Load操作都在该操作之后进行。

此外,在具体实现上,由于 volatile 关键字需要确保变量对于所有线程的可见性,为了利用 CPU 的缓存一致性协议来达到这个目的,Java 虚拟机需要将 volatile 变量存储在主内存中,而不是线程私有的工作内存中。这样在多个线程访问同一个 volatile 变量时,它们各自的工作内存中的缓存都会失效,从而强制从主存中重新获取该变量的值,确保了变量的可见性。

总之,volatile 可以通过内存屏障和 CPU 的缓存一致性协议来实现变量的可见性和禁止指令重排序,从而保证多线程之间的同步性。因此,在高并发的多线程编程中,合理使用 volatile关键字是确保程序正确性的重要手段之一。

9.Volatile底层的内存屏障是如何实现的?

答:volatile 关键字可以确保多个线程之间对变量的可见性,即当一个线程修改了该变量的值时,其他线程能够立即看到这个新值。其中实现可见性的原理主要依赖于内存屏障(Memory Barrier)和编译器对代码的优化。

在 Java 中,内存屏障是一种可以防止指令重排的指令。它可以确保执行到内存屏障之前的所有操作都已经完成后,才能执行到内存屏障之后的操作。这样就可以避免由于编译器的优化或者硬件的乱序执行等问题,导致变量的可见性问题。

具体地说,Java 中的 volatile 关键字可以通过以下方式来保证变量的可见性:

  1. 在写入volatile 变量时,会在写入操作之后插入一条 Store Memory Barrier,确保该操作之前的所有 Store
    操作都已经完成,这样可以保证该写入操作对其他线程可见。

  2. 在读取volatile 变量时,会在读取操作之前插入一条 Load Memory Barrier,确保在该读取操作完成之前,之前的所有Load 和 Store 操作都已经完成。这样可以保证该读取操作获取到的是最新的数据。

总之,Java 中的 volatile关键字可以通过内存屏障来保证可见性和禁止指令重排序,这样可以确保多个线程之间的同步操作能够正常进行,从而避免了很多由于编译器优化和硬件执行乱序等引起的并发问题。

10.内存重排序都会发生在哪?

内存重排(Memory Reordering)是指编译器、CPU 或者其他硬件设备在执行程序时为了提高性能而对指令的执行顺序进行调整的现象。内存重排可以分为三种类型:

  • 编译器优化的重排;
  • 处理器重排;
  • 内存系统的重排;

这些重排序可能会在程序执行时导致一些困扰,特别是在多线程程序中。为了避免因重排而导致程序的行为变得不可预测,Java 内存模型规定了一些规则以确保程序的正确性。

Java 内存模型规定,编译器和处理器遵循的是数据之间的依赖关系,并保证这些依赖关系不会被重排序,因此只有不存在数据依赖关系的情况下,才能进行指令的重排序。

具体来说,以下三种情况可能会导致内存重排:

  • 编译器重排:在编译程序时,编译器可以通过对指令的重排来优化程序的性能。例如,在不影响程序正确性的前提下,编译器可以将多条指令合并成一条,或者将多个变量存储到同一个寄存器中,从而提高程序的运行效率。
  • 处理器重排:在现代处理器的执行过程中,同样会根据指令之间的依赖关系进行指令的调度和重排。例如,因为处理器可能会使用超标量处理技术,同时处理多条指令,因此在执行过程中,处理器可能会对指令进行乱序执行或延迟执行等操作。
  • 内存系统重排:在现代计算机中,内存系统对内存访问做了许多优化和加速的调整,例如 CPU
    缓存、内存预读、分页等等。这些操作可能导致内存的写入和读出顺序和程序中的顺序不一致。

总之,在 Java 中,为了避免因内存重排,导致程序的行为变得不可预测,可以使用 volatile、synchronized、Lock 等方式来保证并发安全性。在使用这些保证并发安全性的方式时,会隐式地禁止编译器和处理器的内存重排优化,从而保证程序的正确性。

11.简述 BIO,NIO 的具体使用及原理?

答:BIO是Java传统的同步阻塞式IO模型,它的工作原理是同步阻塞的,即每个客户端连接都由一个独立的线程来处理,当连接数较大时会出现系统资源耗尽的问题。BIO使用InputStream和OutputStream来进行IO操作,读取数据时需要在输入流上阻塞等待数据,直到数据到达或等待超时,写数据时需要等待写操作完成后才能继续下一步操作。BIO不适用于高并发的场景。

NIO(New IO)是Java的非阻塞IO模型,它提供了通道(Channel)和缓冲区(Buffer)的概念,非常适合于网络编程中的高并发场景。NIO采用了"多路复用器",即Reactor模型,一个线程可以同时监听多个连接,来提升性能,但是它的使用比BIO要复杂。在NIO的实现中,客户端和服务器端都有Selector,Selector可以不断的轮询多个Channel的状态,而且是同样通过轮询的方式来处理多个Channel上的事件,也就是所谓的"轮询事件驱动",包含接收新连接、读取数据、发送数据等等,通过一个个事件驱动,来完成非阻塞的操作。当连接建立成功后,主线程可以继续监听新连接的到来,由Worker线程池处理连接上的事件,从而解决了BIO模型中线程阻塞的问题。

在使用上,BIO的编程模式是阻塞模式,无法同时管理多个连接,如果并发连接数增长,会导致服务器性能的急剧下降;而NIO则是非阻塞模式,可以同时处理多个连接,并且连接数增长时性能的下降趋势相对比较平缓。在实际开发应用中,可以根据应用程序的实际情况来选择使用BIO或NIO,如对于连接较少、但并发比较高的场景,NIO是更好的选择,而对于连接比较少、但并发性较低的场景,BIO则表现更优。


总结

后期不定期更新。

参考

Java基础面试16问
synchronized是可重入锁吗,及其实现原理?
volatile与内存屏障

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值