Java并发(总结自用)

Java并发

1. 程序、进程,线程是什么?进程与线程的区别?

概念
 程序是为完成特定任务、用某种语言编写的一组指令的集合。即指一段静态的代码,静态对象。
 进程是程序的一次执行过程,或是正在运行的一个程序,是一个动态的过程,是操作系统资源分配的基本单位。
 线程,进程可进一步细化为线程,是一个程序内部的一条执行路径,换言之,一个进程可包含多个线程,是处理器任务调度和执行的基本单位。
从JVM内存模型的角度理解:
 程序的一次执行过程对应着一个进程
 一个进程对应着一个JVM实例
 一个JVM实例中只有一个运行时数据区
 一个运行时数据区只有一套方法区和堆
 一个进程中的多个线程共享同一个方法区和堆
 每一个线程拥有独立的一套虚拟机栈、程序计数器和本地方法栈
进程和线程的区别
 内存分配:进程之间的地址空间和资源是相互独立的,一个进程中的多个线程会共享共享进程的堆和方法区。
 资源开销:每个进程具备各自的数据空间,进程之间的切换开销较大。属于同一进程的线程会共享堆和方法区,线程之间的切换资源开销较小。
在这里插入图片描述

2. 并行和并发是什么?两者间的区别与联系?

概念
 并行:多个处理器或者多核处理器同时处理多个任务。在同一时间点,多个任务同时运行。
 并发:一个处理器按时间片轮流处理多个任务。在同一时间点,多个任务并不会同时运行。
区别与联系
 并发指的是多个任务交替进行,而并行则是指真正意义上的“同时进行”。
 实际上,如果系统内只有一个CPU,而使用多线程时,那么真实系统环境下不能并行,只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个CPU的系统中。

3. 多线程是什么?有什么优点、缺点?

概念
 单核CPU,其实是一种假的多线程,因为在一个时间单元内,也只能执行一个线程的任务。但是因为CPU时间单元特别短,切换的很快,所以就有同时执行的错觉。如果是多核CPU的话,才能更好的发挥多线程的效率。
 一个Java应用程序java.exe,其实至少有三个线程:main()主线程,gc()垃圾回收线程,异常处理线程。当然如果发生异常,会影响主线程。
优点
 提高应用程序的响应。对图形化界面更有意义,可增强用户体验;
 提高计算机系统CPU的利用率;
 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改
缺点
 上下文切换问题,频繁的上下文切换会影响多线程的执行速度;
 线程安全问题,会出现死锁。

4. 线程的上下文切换是什么?存在问题?怎么解决?

 概念
即便是单核的CPU也会支持多线程,CPU会给每个线程分配时间片来实现这个机制。时间片是CPU分配给每个线程的执行时间,一般分配的时间片非常短,所以处理器会在线程间不断切换。而每次切换时,需要保存当前任务的状态,因为下次切换回这个任务时还要加载这个任务的状态继续执行,从保存任务状态到再次加载的过程就是一次上下文切换。
 存在问题
上下文切换非常损耗性能,过于频繁反而无法发挥出多线程编程的优势。
系统线程会占用非常多的内存空间。
过多的线程切换会占用大量的系统时间。
 解决方案
减少上下文切换可以采用无锁并发编程,CAS算法,使用最少的线程和使用协程。
 无锁并发编程:可以参照concurrentHashMap锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
 CAS算法:利用Atomic下使用CAS算法来更新数据,使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换。
 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态。
 协程:
线程的上下文切换涉及到从【用户态】->【内核态】->【用户态】的过程,并且上下文中包含很多的数据,整个过程也非常耗时。相较而言,协程的上下文切换则快了很多,它只需在【用户态】即可完成上下文的切换,并且需要切换的上下文信息也较少。
协程上下文切换只涉及CPU上下文切换,指少量寄存器(PC / SP / DX)的值修改,协程切换非常简单,就是把当前协程的 CPU 寄存器状态保存起来,然后将需要切换进来的协程的 CPU 寄存器状态加载上就OK了。而对比线程的上下文切换则需要涉及模式切换(从用户态切换到内核态)、以及 16 个寄存器、PC、SP…等寄存器的刷新。
协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。并且,协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
通俗理解:把原来每个线程分别负责的任务压缩到少量线程中,每个线程中用多个协程来实现原来线程级别的任务,因为协程包装了系统IO,协程内遇到IO不会导致当前线程被挂起,以起到最大化利用时间片,减少线程调度开销的作用。也可以说协程是用户级线程。
具体场景:原来需要10000个线程来处理任务,使用协程后,我们只需要启动100个线程,每个线程上运行100个协程,因为协程包装了系统IO,协程内遇到IO不会导致当前线程被挂起,以起到最大化利用时间片,减少了线程切换开销。在有大量IO操作业务的情况下,我们采用协程替换线程,可以到达很好的效果,一是降低了系统内存,二是减少了系统切换开销,因此系统的性能也会提升。

5. 如何理解死锁?为什么会出现死锁?如何解决?

 概念
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
 造成死锁的原因
系统资源不足,进程运行推进的顺序不合适,资源分配不当等。
 产生死锁的必要条件
 互斥条件:一个资源在同一时刻只能被一个进程使用。
 请求与保持条件:一个进程因请求被占资源而阻塞时,对已获得的资源保持不放。
 不剥夺条件:线程已获得的资源在未使用完不能被其他线程剥夺,只能由自己使用完释放资源。
 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
解决方案:破坏产生死锁的条件
在这里插入图片描述

6. 死锁、活锁、饥饿是什么?为什么会出现饥饿?三者间有什么区别?

 概念
 死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。
 活锁是指任务或者执行者没有被阻塞,由于某些条件没有被满足,导致线程一直重复尝试、失败、尝试、失败。简单来讲就是每个线程可以使用某个资源,但是他们都太客气了,将该资源让出来给其他线程使用,导致所有线程都无法使用资源。
 饥饿是指一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。以打印机打印文件为例,当有多个线程需要打印文件,系统按照短文件优先的策略进行打印,但当短文件的打印任务一直不间断地出现,那长文件的打印任务会被一直推迟,导致饥饿。
 产生饥饿的原因
 高优先级的线程占用了低优先级线程的CPU时间。有些低优先级的线程可能一直被高优先级线程占用资源,导致无法获取CPU资源。
 无法保证线程进入同步代码块的顺序,线程被永久阻塞在等待进入同步块的状态。Java的同步代码区也是一个导致饥饿的因素。Java的同步代码区对线程允许进入的次序没有任何保障。这就意味着理论上存在一个试图进入该同步区的线程处于被永久堵塞的风险,因为其他线程总是能持续地先于它获得访问,这即是“饥饿”问题,而一个线程被“饥饿致死”正是因为它得不到CPU运行时间的机会。
 无法保证指定的线程一定会被唤醒,线程在等待一个本身也处于永久等待完成的对象。如果多个线程处在wait()方法执行中,而对其调用notify()不会保证哪一个线程会获得唤醒,任何线程都有可能处于继续等待的状态。因此存在这样一个风险:一个等待线程从来得不到唤醒,因为其他等待线程总是能被获得唤醒。
 活锁和死锁的区别:
活锁是在不断地尝试、死锁是在一直等待。活锁有可能自行解开、死锁无法自行解开。死锁和活锁最大的区别在于:死锁状态下所有线程都是处于阻塞状态的,活锁下的线程,虽然也不能正常运行,但线程本身都是处于运行状态下的。
 死锁、饥饿的区别:
饥饿可自行解开,死锁不行。

7. 线程的生命周期

在这里插入图片描述

 新建状态(New):当线程对象被创建后,即进入了新建状态,如:Thread t = new MyThread();
 就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行t.start()此线程立即就会执行;
 运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
 等待阻塞
运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
 同步阻塞
线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
 其他阻塞
通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
 就绪、运行和死亡状态之间的联系
 就绪状态转换为运行状态:当此线程得到处理器资源;
 运行状态转换为就绪状态:当此线程主动调用yield()方法或在运行过程中失去处理器资源。
 运行状态转换为死亡状态:当此线程执行体执行完毕或发生了异常。
此处需要特别注意的是:当调用线程的yield()方法时,线程从运行状态转换为就绪状态,但接下来CPU调度就绪状态中的哪个线程具有一定的随机性,因此,可能会出现A线程调用了yield()方法后,接下来CPU仍然调度了A线程的情况。

8. Object类有关方法,wait()与notify()和notifyAll()

对象名.wait():调用wait()方法,该线程释放所有资源,包括cpu资源和锁资源,并且释放锁标志,jvm会把该线程放入等待池,不会自动唤醒,要等待其他线程调用notify()或notifyAll()唤醒该线程才会重新获得锁并且进入就绪状态。
对象名.notify():唤醒一个处于等待阻塞状态的线程,它不能保证哪个线程会被唤醒,这取决于线程调度器ThreadScheduler,具有随机性。
对象名.notifyAll():唤醒所有处于等待阻塞状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态。
这三个方法只有在synchronized方法或synchronized代码块中才能调用,否则会报 java.lang.IllegalMonitorStateException异常。
wait()方法调用后,线程处于阻塞状态并释放所有资源,将线程处于等待池中,其他线程调用notify()会从等待池唤醒任意一个线程并且放入锁池,调用notifyAll()唤醒所有等待池中的线程并放入锁池,锁池里的线程拥有任意争取锁的权力,获得锁的线程将进入就绪状态。
如果使用wait(long time)的方法,达到时间后会自动进入锁池,不需要notify()方法唤醒。

9. Thread类的有关方法

void start(): 启动线程,并执行对象的run()方法。
run(): 线程在被调度时执行的操作。
static void yield():线程让步,暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程。大多数情况下,yield()将导致线程从运行状态转到就绪状态,但很有可能没有效果。

  1. yield是静态方法也是native方法
  2. yield告诉正在执行的线程给线程池中有相同优先级的线程一个机会
  3. yield不能保证正在执行的线程立刻变成Runnable状态
  4. 它仅仅可以使一个线程从running状态变成Runnable状态,而不是wait或者blocked状态
    join():当前线程调用其他线程的join方法,会阻塞当前线程,直到其它线程执行完毕,当前线程才会进入就绪状态。例如在B线程中调用A线程的join()方法,B线程进入阻塞状态,直到A线程结束或者到达指定的时间。
    static void sleep(long millis):(指定时间:毫秒),令当前运行状态的线程在指定时间段内放弃对CPU控制,使其他线程有机会被执行,时间到后重排队,会抛出InterruptedException异常。

yield()与sleep()的比较:
相同:
都是Thread类的静态方法,都不会释放锁,都会让当前线程放弃CPU,交出CPU执行权限。
不同:
yield执行后,只会使线程让出自己的时间片,只是对CPU进行提示,并没有被阻塞,如果CPU没有忽略这个提示,会使得线程上下文的切换,当前调用yield()的线程会进入就绪状态,而当前调用sleep()的线程进入阻塞状态;
yield()会让位给相同或更高优先级的线程,sleep()给其它线程运行的机会,但不考虑其它线程的优先级;
yield()不支持抛出异常,而sleep()可以抛出异常。

join()与sleep()的比较:
相同:
都会使当前线程进入阻塞状态,都会让当前线程放弃CPU,交出CPU执行权限;
可以被中断,被中断时,会抛出 InterrupptedException 异常
不同:
join()的内部实现是wait(),所以使用join()方法是会释放锁的,而sleep 方法不会释放锁,它只是让线程阻塞一段时间。由于不会释放锁,所以容易造成死锁;
wait()与sleep()的比较:
相同:
都会使当前线程进入阻塞状态,都会让当前线程放弃CPU,交出CPU执行权限。
不同:
wait()是 Object 类的方法,sleep()是Thread 类的静态方法;
wait()会释放锁,而sleep()不会释放锁,它只是让线程阻塞一段时间。由于不会释放锁,所以容易造成死锁。
wait()必须在同步方法或同步代码块中使用,否则会报
java.lang.IllegalMonitorStateException异常,而sleep()可以在任何地方使用,可以抛出异常(InterruptedException)。
总结:方法的比较切入点包括当前线程状态、是否释放锁、是否支持抛出异常、使用场景

在这里插入图片描述

10. wait()方法一般在循环块中使用还是if块中使用?

在JDK官方文档中明确要求在循环中使用,否则可能出现虚假唤醒的可能。
wait() 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理业务逻辑前,循环检测是否满足相应条件会更好。

11. 创建线程一共有哪几种方法?

继承Thread类创建线程
首先继承Thread类,重写run()方法,在main()函数中调用子类实例的start()方法。
实现Runnable接口创建线程
Runnable接口实现类实例对象作为参数创建thread对象调用Thread对象的start()方法。
用Callable和Future创建线程
Callable接口实现类实例对象作为参数创建FutureTask对象作为参数创建Thread对象调用Thread对象的start()方法。
用线程池,例如用Executor框架。

12. Runnable和 Callable有什么区别?

相同:
两者都是接口,需要调用Thread.start启动线程
不同:
Runnable的run()只执行逻辑,不返回结果;Callable中的call()执行逻辑之后,会返回结果;
Runnable不支持抛出异常,异常需要在run方法中自己处理;Callable可以抛出异常;
创建线程方式不同。

13. 线程的run()和start()有什么区别?为什么调用start()方法时会执行run()方法,而不直接执行run()方法? 线程的run()和start()有什么区别?

第一,线程是通过Thread对象所对应的方法run()来完成其操作的,而线程的启动是通过start()方法执行的。 run()方法可以重复调用,start()方法只能调用一次
第二,start()方法来启动线程,真正实现了多线程运行,这时无需等待run()方法体代码执行完毕而直接继续执行下面的代码。通过调用Thread类的 start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行run()方法,这里方法run()称为线程体,它包含了要执行的这个线程的内容,run()方法运行结束,此线程随即终止。
run()方法只是类的一个普通方法而已,如果直接调用run方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run()方法体执行完毕后才可继续执行下面的代码,这样就没有达到写线程的目的。
第三,调用start()方法可以开启一个线程,而run()方法只是thread类中的一个普通方法,直接调用run()方法还是在主线程中执行的。

14. 线程通信的方法有哪些?

线程之间的交互称为线程通信。
锁与同步 wait()/notify()或notifyAll() 信号量 管道

15. 为什么wait()、notify()、notifyAll()被定义在Object类中而不是在Thread类中?

因为这三个方法必须由锁对象调用,任意对象都可以作为synchronized的同步锁,而Object类是所有对象的父类,因此这三个方法只能在Object类中声明。

16. 为什么wait(),notify()和notifyAll()必须在同步方法或者同步块中被调用?

当一个线程需要调用对象的wait()时,此线程必须拥有该对象的锁,接着它就会释放这个对象锁并进入等待阻塞状态,直到其它线程调用notify()唤醒。
同样,当一个线程需要调用对象的notify()或notifyAll()时,它会释放这个对象的锁,以便其它等待阻塞的线程获得对象锁。
由于这些调用方法的必要条件是当前线程必须具有对该对象的监控权(加锁),只能通过同步来实现,所以它们只能在同步方法或者同步块中被调用。

17. 为什么Thread类的sleep()和yield()方法是静态的?

sleep()和yield()都需要处于运行状态的线程进行调用,那些处于阻塞或者等待阻塞状态的线程调用这个方法是无意义的,所以这两个方法是静态的。
之所以写成静态方法,是告诉开发人员,你在哪里调用,调用的都是同一个方法,并非某个线程独有的实例方法,它只对当前运行的线程有效。

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

使用退出标志,使线程正常退出,也就是当run方法完成后线程终止;
使用stop()方法强行停止线程,但该方法已经被废弃。因为这样线程不能在停止前保存数据,会出现数据完整性问题。
使用interrupt方法中断线程

19. 如何唤醒一个阻塞的线程?

如果线程是由于wait()、sleep()、join()、yield()等方法进入阻塞状态的,是可以进行唤醒的。如果线程是IO阻塞是无法进行唤醒的,因为IO是操作系统层面的,Java代码无法直接接触操作系统。
yield():使得当前线程放弃CPU时间片,但随时可能再次得到CPU时间片进而激活。
join():当前线程A调用另一个线程B的join()方法,当前线程转A入阻塞状态,直到线程B运行结束,线程A才由阻塞状态转为可执行状态。
sleep():调用该方法使得线程在指定时间内进入阻塞状态,等到指定时间,线程再次获取到CPU时间片进而被唤醒。
wait():可用notify()或notifyAll()方法唤醒。

20. Java如何实现两个线程之间的通信和协作?

syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll();
ReentrantLock类加锁的线程的Condition类的await()/signal()/signalAll();
通过管道进行线程间通信:1)字节流;2)字符流,就是一个线程发送数据到输出管道,另一个线程从输入管道读数据。

21. 同步方法和同步代码块哪个效果更好? (同步方法与同步代码块的区别)

结论是很明显的,同步代码块比同步方法好。原因如下:
1) 我们只需要对临界区的代码进行同步
因为多线程只会对临界区的代码访问顺序敏感,大多数情况下,我们可能只是方法中某一段内容需要同步,同步代码块可以帮助我们只在必要的地方进行同步。
当然如果整个方法的内容都需要同步,同步代码块和同步方法其实效果是一样的。
2) 在同步代码块中,我们可以自由的选择锁
在同步代码块中,我们可以自由选择锁,同步代码块中,我们可以自由选择任何一个java对象实例为锁,但是同步方法只能是这个对象的实例,这就会带来一个问题,假如我们类中定义了俩个不同的实例同步方法,这俩个方法在业务上并没有太多关联,单例情况下,当某个线程在调用其中一个同步方法时,其他线程就无法调用另外一个实例同步方法,必须等到一个实例同步方法执行完成,释放锁,其他线程才能得到锁,如果我们使用的是同步代码,自由定义锁,这样就可以避免多个同步实例彼此之间的影响。

22. 什么是线程同步?什么是线程互斥?线程同步和线程互斥的关系?它们是如何实现的?

线程互斥是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性,访问是无序的。
线程同步是指在互斥的基础上,通过其它机制实现访问者对资源的有序访问。
线程同步其实已经实现了互斥,所以同步是一种更为复杂的互斥。
线程互斥是一种特殊的同步。
线程同步的实现方法:同步方法、同步代码块、wait()和notify()、使用volatile实现线程同步、使用重入锁实现线程同步、使用局部变量实现线程同步、使用阻塞队列实现线程同步。

23. 在Java程序中如何保证线程的运行安全?

为什么会不安全?
一是写不安全。因为在JVM中的内存管理,并不是所有内存都是线程私有的,Heap(Java堆)中的内存是线程共享的。而 Heap 中主要是存放对象的,这样多个线程访问同一个对象时,就会使用到同一块内存了,在这块内存中存着的成员变量就会受到多个线程的操作。
二是读不安全。因为多个线程虽然访问对象时是使用的同一块内存(这块内存可称为主内存),但是为了提高效率,每个线程有时会都会将读取到的值缓存在本线程内(具体因不同 JVM 的实现逻辑而有不同,所以缓存不是必然的),这些缓存的数据可称为副本数据。因为读不安全导致了数据不一致问题。
总结线程安全问题主要体现在原子性、可见性和有序性
 原子性:一个或者多个操作在 CPU 执行的过程中不被中断的特性。线程切换带来的原子性问题。
 可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。缓存导致的可见性问题。
 有序性:程序执行的顺序按照代码的先后顺序执行。编译优化带来的有序性问题。
解决方法:
 原子性问题:可用JDK Atomic开头的原子类、synchronized、LOCK来解决
 可见性问题:可用synchronized、volatile、LOCK来解决;
 有序性问题:可用Happens-Before 规则来解决。

24. 线程类的构造方法、静态块是被哪个线程调用的?

线程类的构造方法、静态块是被new这个线程类所在的线程所调用的,而run()方法里面的代码才是被线程自身所调用的。
一个很经典的例子: 假设main()函数中new了一个线程Thread1,那么Thread1的构造方法、静态块都是main线程调用的,Thread1中的run()方法是自己调用的。
假设在Thread1中new了一个线程Thread2,那么Thread2的构造方法、静态块都是Thread1线程调用的,Thread2中的run()方法是自己调用的。

25. 一个线程运行时异常会发生什么? 线程数量过多会造成什么异常?

如果该异常被捕获或抛出,则程序继续运行。
如果异常没有被捕获该线程将会停止执行。
第一,线程的生命周期开销非常高。
第二,消耗过多的CPU资源。如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争CPU 资源时还将产生其他性能的开销。
第三,降低稳定性。JVM在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括JVM 的启动参数、Thread 构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出OutOfMemoryError 异常。

26. Java内存的可见性

内存可见性是指当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。
可见性解决方案:使用synchronized进行加锁或使用volatile关键字修饰。
为什么使用synchronized加锁或使用volatile关键字修饰就保证了变量的内存可见性了?
 因为当一个线程进入 synchronized代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。
 使用volatile修饰共享变量后,每个线程要操作变量时会从主内存中将变量拷贝到本地内存作为副本,当线程操作变量副本并写回主内存后,会通过CPU总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。

比较底层的知识点:总线嗅探机制
由于 CPU 与内存之间加入了缓存,在进行数据操作时,先将数据从内存拷贝到缓存中,CPU 直接操作的是缓存中的数据。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。注意,缓存的一致性问题,不是多处理器导致,而是多缓存导致的。

总结:使用 volatile 和 synchronized 锁都可以保证共享变量的可见性。相比 synchronized 而言,volatile 可以看作是一个轻量级锁,所以使用 volatile 的成本更低,因为它不会引起线程上下文的切换和调度。但 volatile 无法像 synchronized 一样保证操作的原子性。

在多线程环境下,volatile 关键字可以保证共享数据的可见性,但是并不能保证对数据操作的原子性。也就是说,多线程环境下,使用 volatile 修饰的变量是线程不安全的。
要解决这个问题,我们可以使用锁机制,或者使用原子类(如 AtomicInteger)。
这里特别说一下,对任意单个使用 volatile 修饰的变量的读/写是具有原子性,但类似于 flag = !flag 这种复合操作不具有原子性。简单地说就是,单纯的赋值操作是原子性的。

27. 什么是synchronized关键字?

在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而synchronized关键字则是用来保证线程同步的。
synchronized可以修饰普通方法、静态方法,同时还可以直接定义代码块,但是归根结底它上锁的资源只有两类:一个是对象,一个是类。
修饰普通方法时,synchronized的锁对象是当前类的实例化对象(this);
修饰静态方法时,锁对象是为当前类class对象,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
作用在同步代码块上时,理论上锁对象可以为任意非空对象;

28. synchronized关键字三大特性是什么?

synchronized关键字可以保证并发编程的三大特性:原子性、可见性、有序性,而volatile关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的synchronized。
原子性:一个或多个操作要么全部执行成功,要么全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源。
可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。执行synchronized时,会对应执行 lock 、unlock原子操作,保证可见性。
有序性:程序的执行顺序会按照代码的先后顺序执行。

29. synchronized关键字可以实现什么类型的锁?

悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。
非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。
可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。
独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。

30. synchronized关键字的底层原理

在jdk1.6之前,synchronized被称为重量锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁和轻量级锁。
Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter 和 monitorexit 指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。

31. 为什么引入偏向锁、轻量级锁,介绍下synchronized锁升级流程?(jdk1.6为什么要对synchronized进行优化?做了哪些优化?)

Synchronized 在 jdk1.6 版本之前,是通过重量级锁的方式来实现线程之间锁的竞争。
之所以称它为重量级锁,因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock来实现的。操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。用户态与内核态
在 jdk1.6 版本中,synchronized 增加了锁升级的机制,来平衡数据安全性和性能。单来说,就是线程去访问 synchronized 同步代码块的时候,synchronized根据线程竞争情况,会先尝试在不加重量级锁的情况下去保证线程安全性。所以引入了偏向锁和轻量级锁的机制。
偏向锁
偏向锁就是直接把当前锁偏向于某个线程,简单来说就是通过CAS修改偏向锁标。这种锁适合同一个线程多次去申请同一个锁资源并且没有其他线程竞争的场景。引入偏向锁的目的:减少只有一个线程执行同步代码块时的性能消耗。
引入轻量级锁的目的:在多线程交替执行同步代码块时(未发生竞争),避免使用重量级锁带来的性能消耗。但多个线程同时进入临界区(发生竞争)则会使得轻量级锁膨胀为重量级锁。
轻量级锁
轻量级锁也可以称为自旋锁,基于自适应自旋的机制,通过多次自旋重试去竞争锁。自旋锁优点在于它避免了用户态到内核态的切换带来的性能开销。Synchronized 引入了锁升级的机制之后,如果有线程去竞争锁:首先,synchronized 会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,表示加锁成功直接返回。如果竞争锁失败,说明当前锁已经偏向了其他线程。需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数去尝试抢占锁资源,如果在轻量级锁状态下还是就只能升级到重量级锁,在重量级锁状态下,没有竞争到锁的线程就会被阻塞,线程状态是 Blocked。处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。
引入自旋机制的原因:因为阻塞和唤起线程都会引起操作系统用户态和核心态的转变,对系统性能影响较大,而自旋等待可以避免线程切换的开销。
自旋等待虽然可以避免线程切换的开销,但它也会占用处理器的时间。如果持有锁的线程在较短的时间内释放了锁,短到比线程两次上下文切换时间要少的情况下,使用自旋锁是划算的。如果持有锁的线程很长时间都不释放锁,自旋的线程就会白白浪费资源,所以一般线程自旋的次数必须有一个限制,该次数可以通过参数调整,一般默认为10。
自适应自旋锁:JDK1.6引入了自适应自旋锁,自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。
总结
总的来说,Synchronized 的锁升级的设计思想,在我看来本质上是一种性能和安全性的平衡,也就是如何不牺牲性能的情况下保证线程安全性。这种思想在编程领域比较常见,比如 Mysql 里面的 MVCC 使用版本链的方式来解决多个并行事务的竞争问题。
以上就是我对这个问题的理解。
Mysql 之 MVCC
MVCC之 undo log 和 redo log 区别
在这里插入图片描述

32. 了解锁消除吗?了解锁粗化吗?

锁消除是指Java虚拟机在即时编译时,进行逃逸分析。分析synchronized锁对象是不是只可能被一个线程加锁,不存在其他线程来竞争加锁的情况。这时就可以消除该锁了,提升执行效率。编译就不用加入monitorenter和monitorexit指令。
一般情况下,为了提高性能,总是将同步块的作用范围限制到最小,这样可以使得需要同步的操作尽可能地少。但如果一系列连续的操作一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。可以看下面这个经典案例。

33. 当线程1进入到一个对象的synchronized方法A后,线程2是否可以进入到此对象的synchronized方法B?

不能,线程2只能访问该对象的非同步方法。因为执行同步方法时需要获得对象的锁,而线程1在进入sychronized修饰的方A时已经获取到了锁,线程2只能等待,无法进入到synchronized修饰的方法B,但可以进入到其他非synchronized修饰的方法。

34. 指令重排序是什么?为什么代码会重排序?存在什么问题?

指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致。计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,目的是为了提高性能,本质上是一种性能优化的手段。主要有以下三种重排序场景。
 编译器层面。编译器的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指令进行合理的重排序优化来提升性能。
 CPU层面。现代处理器多采用指令级并行技术来将多条指令重叠执行。对于不存在数据依赖的程序,处理器可以对机器指令的执行顺序进行重新排列。
 内存层面。因为CPU缓存使用缓冲区的方式(Store Buffere )进行延迟写入,这个过程会造成多个CPU缓存可见性的问题,这种可见性的问题导致结果的对于指令的先后执行显示不一致,看上去像是在乱序执行。
存在问题:
多线程环境下会出现内存可见性问题,工作内存和主内存,编译器处理器重排序导致的可见性问题。
有序性问题,程序的读写顺序与内存的读写顺序不一样。

35. volatile 关键字有什么用?它的实现原理是什么?特性有哪些?

volatile 关键字有两个作用。一是可以保证在多线程环境下共享变量的可见性。二是通过增加内存屏障防止多个指令之间的重排序,从而保证指令有序性。
首先可见性方面,我理解的可见性,是指当某一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值。其实这个可见性问题,我认为本质上是由几个方面造成的。CPU 层面的高速缓存,在 CPU 里面设计了三级缓存去解决CPU运算效率和内存IO效率问题,但是带来的就是缓存的一致性问题,而在多线程并行执行的情况下,缓存一致性就会导致可见性问题。所以,对于增加了 volatile 关键字修饰的共享变量,JVM 虚拟机会自动增加一个#Lock汇编指令,这个指令会根据CPU型号自动添加总线锁或/缓存锁
我简单说一下这两种锁,总线锁是锁定了 CPU 的前端总线,从而导致在同一时刻只能有一个线程去和内存通信,这样就避免了多线程并发造成的不可见性。缓存锁是对总线锁的优化,因为总线锁导致了CPU的使用效率大幅度下降,所以缓存锁只针对CPU三级缓存中的目标数据加锁,缓存锁是使用MESI(CPU缓存一致性协议)一致性来实现的。
其次指令重排序方面,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境下导致可见性问题。计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。指令重排序本质上是一种性能优化的手段,它来自于几个方面。
 编译器层面。编译器的优化,在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指令进行合理的重排序优化来提升性能。
 CPU层面。针对MESI协议的更进一步提高CPU的利用率,引入了StoreBuffer 机制,而这一种优化机制会导致CPU的乱序执行。当然为了避免这样的问题,CPU提供了内存屏障指令,上层应用可以在合适的地方插入内存屏障来避免CPU指令重排序问题。
所以,如果对共享变量增加了volatile 关键字,那么在编译器层面,就不会去触发编译器优化,同时在JVM 里面,会插入内存屏障指令来避免重排序问题。内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。
当然,除了volatile 以外,从JDK5开始,JVM就使用了一种Happens-Before模型去描述多线程之间的内存可见性问题。如果两个操作之间具备 Happens-Before 关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加volatile关键字来提供可见性保障。
并发编程的三大特性为可见性、有序性和原子性。通常来讲volatile可以保证可见性和有序性。
 可见性:volatile可以保证不同线程对共享变量进行操作时的可见性。即当一个线程修改了共享变量时,另一个线程可以读取到共享变量被修改后的值。
 有序性:volatile会通过禁止指令重排序进而保证有序性。
 原子性:对于单个的volatile修饰的变量的读写是可以保证原子性的,但对于i++这种复合操作并不能保证原子性。这句话的意思基本上就是说volatile不具备原子性了。
以上就是我对这个问题的理解。

36. volatile能使一个非原子操作变成一个原子操作吗?

volatile只能保证可见性和有序性,但可以保证64位的long型和double型变量的原子性。
对于32位的虚拟机来说,每次原子读写都是32位的,会将long和double型变量拆分成两个32位的操作来执行,这样多个线程访问long和double型变量的读写就不能保证原子性了,而通过volatile修饰的long和double型变量则可以保证其原子性。

37.synchronized和volatile的区别?

 volatile作用于变量,synchronized作用于代码块或方法。
 volatile主要是保证内存的可见性,即变量在寄存器中的内存是不确定的,需要从主存中读取。synchronized主要是解决多个线程访问资源的同步性。
 volatile仅可以保证数据的可见性,不能保证数据的原子性。synchronized可以保证数据的可见性和原子性。
 volatile不会造成线程的阻塞,synchronized会造成线程的阻塞。

38. synchronized和Lock的区别?

下面我从3个方面来回答:
 从功能方面来看,Lock 和 Synchronized 都是 Java 中用来解决线程安全问题的工具。
 从特性方面来看,synchronized是一个关键字,是依赖JVM实现的。Lock是一个接口,是JDK实现的。Lock 比 Synchronized 的灵活性更高,Lock 可以自主决定什么时候加锁,什么时候释放锁,只需要调用 lock()和unlock()这两个方法就行,同时Lock 还提供了非阻塞的竞争锁方法 tryLock()方法,这个方法通过返回true/false 来告诉当前线程是否已经有其他线程正在使用锁。Synchronized 由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized 锁的释放是被动的,就是当 Synchronized 同步代码块执行完以后或者代码出现异常时才会释放。Lock 提供了公平锁和非公平锁的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。Synchronized 只提供了一种非公平锁的实现,不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。
 从性能方面来看,Synchronized 和 Lock 在性能方面相差不大,在实现上会有一些区别,Synchronized 引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而 Lock 中则用到了自旋锁的方式来实现性能优化。
以上就是我对于这个问题的理解。

39. 怎么理解线程安全?

简单来说,在多个线程访问某个方法或者对象的时候,不管通过任何的方式调用以及线程如何去交替执行。
在程序中不做任何同步干预操作的情况下,这个方法或者对象的执行/修改都能按照预期的结果来反馈,那么这个类就是线程安全的。实际上,线程安全问题的具体表现体现在三个方面,原子性、有序性、可见性。
 原子性是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。这个和数据库里面的原子性是一样的,简单来说就是一段程序只能由一个线程完整的执行完成,而不能存在多个线程干扰。CPU的上下文切换, 是导致原子性问题的核心,而JVM里面提供了Synchronized 关键字来解决原子性问题。
 可见性,就是说在多线程环境下,由于读和写是发生在不同的线程里面,有可能出现某个线程对共享变量的修改,对其他线程不是实时可见的。导致可见性问题的原因有很多,比如 CPU 的高速缓存、CPU 的指令重排序、编译器的指令重排序。
 有序性,指的是程序编写的指令顺序和最终 CPU 运行的指令顺序可能出现不一致的现象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。可见性和有序性可以通过 JVM 里面提供了一个Volatile 关键字来解决。
在我看来,导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升CPU利用率导致的。比如为了提升 CPU 利用率,设计了三级缓存、设计了 StoreBuffer、设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计了编译器的深度优化机制。
以上就是我对这个问题的理解。

40. ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

这个问题我从这三个方面来回答:整体架构,基本功能,性能方面的优化。
 ConcurrentHashMap 的整体架构
 JDK1.7
ConcurrentHashMap的数据结构是由一个Segment数组和多个HashEntry数组组成,Segment存储的是链表数组的形式,每个Segment包含着一个HashEntry数组,HashEntry是一个链表结构,如果要获取HashEntry中的元素,要先获得Segment的锁。
从图可以看出,ConcurrentHashMap定位一个元素的过程需要两次Hash的过程,第一次Hash的目的是定位到Segment,第二次Hash的目的是定位到链表的头部。两次Hash所使用的时间比一次Hash的时间要长,但这样做可以在写操作时,只对元素所在的Segment加锁,不会影响到其他Segment,这样可以大大提高并发能力。
在这里插入图片描述

 JDK1.8
JDK1.8不再是Segment+HashEntry的结构了,而是和HashMap类似的结构,Node数组+链表/红黑树,采用链式寻址法来解决 hash 冲突。当 hash冲突比较多的时候,会造成链表长度较长,这种情况会使得ConcurrentHashMap 中数据元素的查询复杂度变成 O(N)。因此在JDK1.8中,引入了红黑树的机制。当数组长度大于 64 并且链表长度大于等8的时候,单向链表就会转换为红黑树。另外,随着 ConcurrentHashMap 的动态扩容,一旦链表长度小于8,红黑树会退化成单向链表。
在这里插入图片描述
 ConcurrentHashMap 的基本功能
ConcurrentHashMap 本质上是一个HashMap,因此功能和HashMap 一样,但是ConcurrentHashMap 在 HashMap 的基础上,提供了并发安全的实现。并发安全主要体现在,在JDK1.7中由于Segment实现了ReentrantLock,所以Segment有锁的性质。在JDK1.8中,采用CAS+synchronized来保证线程安全。
 ConcurrentHashMap 在性能方面的优化
如果在并发性能和数据安全性之间做好平衡,在很多地方都有类似的设计,比如cpu的三级缓存、Synchronized 的锁升级等等。ConcurrentHashMap 也做了类似的优化,主要体现在以下几个方面:
 锁粒度:在JDK1.7,锁定的是 Segment,锁的范围要更大,因此性能上会更低,而在JDK1.8中synchronized只锁链表或红黑树的头节点,是一种相比于Segment更为细粒度的锁,锁的竞争变小,所以效率更高。
 查询效率:引入红黑树,降低了数据查询的时间复杂度,红黑树的时间复杂度是O(logN)。
 安全扩容:当数组长度不够时,ConcurrentHashMap 需要对数组进行扩容,在扩容的实现上,ConcurrentHashMap 引入了多线程并发扩容的机制,简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据迁移,从而提升了扩容过程中数据迁移的效率。

41.ConcurrentHashMap结构中变量使用volatile和final修饰有什么作用?

final修饰变量可以保证变量不需要同步就可以被访问和共享;
volatile修饰变量可以保证内存的可见性,配合CAS操作可以在不加锁的前提下支持并发。如ConcurrentHashMap的get()不需要加锁,因为Node和HashEntry的元素value和指针next使用volatile修饰的,在多线程环境下线程A修改节点的value或者新增节点的时候是对线程B可以见的。
在这里插入图片描述

42. ConcurrentHashMap有什么缺点?

因为ConcurrentHashMap在更新数据时只会锁住部分数据,并不会将整个表锁住,读取的时候也并不能保证读取到最近的更新,只能保证读取到已经顺利插入的数据。

43. ConCurrentHashMap的key,value是否可以为null?为什么?HashMap中的key、value是否可以为null?

ConCurrentHashMap中的key和value为null会出现空指针异常,而HashMap中的key和value值是可以为null的。
首先ConCurrentHashMap中的key和value不可以为null,是为了避免在多线程环境下出现歧义问题。如果key或value为null,如果调用get(key)方法返回null,则无法判断key对应的value的值为null,还是该key本身就不存在。在多线程的情况下使用containsKey(key)来做这个判断是存在问题的,因为在containsKey(key)和get(key)两次调用的过程中,key的值有可能已经发生了改变,出现线程不安全,而ConCurrentHashMap又是一个线程安全的集合,所以自然就不允许key或value为null。
而在单线程场景下的HashMap中,不需要考虑线程安全性,所以该问题的核心本质是ConCurrentHashMap是一个并发安全集合。
以上是我对该问题的理解。

44. ConcurrentHashMap 1.7和1.8的区别

 实现结构
 1.7: Segment数组 + HashEntry数组 + 链表
 1.8: Node数组 + 链表/红黑树
 线程安全
 1.7: 分段锁机制。对整个桶数组进行分割分段(Segment),每一把锁只锁容器中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
 1.8: 移除Segment,使锁的粒度更小,CAS操作+ Synchronized关键字
 put()
 1.7: 先定位Segment,再定位桶,put全程加锁,没有获取锁的线程提前找桶的位置,并最多自旋64次获取锁,超过则挂起。
 1.8: 由于移除了Segment,类似HashMap,可以通过key的hash值定位Node节点进行判断,1、如果当前位置为空,则可以写入数据,利用CAS机制尝试写入数据,如果写入失败,说明存在竞争,将会通过自旋来保证成功;2、如果当前的hashcode值等于MOVED(-1)则需要进行扩容(CAS扩容保证线程安全);3、如果前面条件都不满足,则通过synchronized同步锁将数据写入,当链表长度大于8,Node数组数大于64时,链表转换为红黑树结构。
 get()
基本类似,由于value声明为volatile,保证了修改的可见性,因此不需要加锁。根据key的hashcode寻址到具体的桶上,如果是红黑树则按照红黑树的方式去查找数据,如果是链表就按照遍历链表的方式去查找数据。
 resize()
 1.7:跟HashMap步骤一样,只不过是搬到单线程中执行,避免了HashMap在1.7中扩容时死循环的问题,保证线程安全。
 1.8:支持并发扩容,HashMap扩容在1.8中由头插改为尾插(为了避免死循环问题),ConcurrentHashmap也是,迁移也是从尾部开始,扩容前在桶的头部放置一个hash值为-1的节点,这样别的线程访问时就能判断是否该桶已经被其他线程处理过了。
 size()
 1.7:很经典的思路:计算两次,如果不变则返回计算结果,若不一致,则锁住所有的Segment求和。
 1.8:用baseCount来存储当前的节点个数,这就设计到baseCount并发环境下修改的问题。

45. ConcurrentHashMap的size()方法是线程安全的吗?为什么?

size()本身的计算是线程安全的,但是size()获取的数据并不是实时的,也就是put()和size方法没有做同步导致的。所以我认为ConcurrentHashMap的size()是非线程安全的。也就是说,当有线程调用put()在添加元素的时候,其他线程在调用size()获取的元素个数和实际存储元素个数是不一致的。
原因是size()方法是一个非同步方法,put()方法和size()方法并没有实现同步锁。put()方法的实现逻辑是:在hash表上添加或者修改某个元素,然后再对总的元素个数进行累加。其中,线程的安全性仅仅局限在hash表数组粒度的锁同步,避免同一个节点出现数据竞争带来线程安全问题。
数组元素个数的累加方式用到了两个方案:
 当线程竞争不激烈的时候,直接用CAS的方式对一个volatile修饰的long类型的变量baseCount做原子递增。
 当线程竞争比较激烈的时候,使用一个CounterCell数组,用分而治之的思想减少多线程竞争,从而实现元素个数的原子累加。

size()方法的逻辑就是遍历CounterCell数组中的每个value值进行累加,再加上baseCount,汇总得到一个结果。所以很明显,size()方法得到的数据和真实数据必然是不一致的。因此从size()方法本身来看,它的整个计算过程是线程安全的,因为这里用到了CAS的方式解决了并发更新问题。但是站在ConcurrentHashMap全局角度来看,put()方法和size()方法之间的数据是不一致的,因此也就不是线程安全的。

46. ConcurrentHashMap迭代器是强一致性还是弱一致性?

与HashMap(强一致性)不同的是,ConcurrentHashMap迭代器是弱一致性。 这里所谓弱一致性是指,当ConcurrentHashMap的迭代器创建后,会遍历哈希表中的元素,在遍历的过程中,哈希表中的元素可能发生变化,如果这部分变化发生在已经遍历过的地方,迭代器则不会反映出来,如果这部分变化发生在未遍历过的地方,迭代器则会反映出来。换种说法就是put()方法将一个元素加入到底层数据结构后,get()可能在某段时间内还看不到这个元素。
这样的设计主要是为ConcurrenthashMap的性能考虑,如果想做到强一致性,就要到处加锁,性能会下降很多。所以ConcurrentHashMap是支持在遍历过程中,向map中添加元素的,而HashMap这样操作则会抛出异常。

47. ThreadLocal 是什么?它的实现原理呢?

 ThreadLocal 是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
 在多线程访问共享变量的场景中,一般的解决办法是对共享变量加锁,从而保证在同一时刻只有一个线程能够对共享变量进行更新,并且基于Happens-Before规则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。

 但是加锁会带来性能的下降,所以 ThreadLocal 用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。

 ThreadLocal的具体实现原理是,在Thread 类里面有一个成员变量ThreadLocalMap,它专门来存储当前线程的共享变量副本,后续这个线程对于共享变量的操作,都是从这个 ThreadLocalMap 里面进行变更,不会影响全局共享变量的值。
ThreadLocal的应用场景挺多:如保存线程上下文信息,可以避免多次传递,打破层次间的约束,在需要的地方可以获取;线程间数据隔离;数据库连接;Session会话管理。在 ThreadLocal 中,除了空间换时间的设计思想以外,还有一些比较好的设计思想,比如线性探索解决 hash 冲突,数据预清理机制、弱引用key 设计尽可能避免内存泄漏等。
以上就是我对这个问题的理解。

48. ThreadLocal会出现内存泄露吗?如何避免?

 第一,什么是内存泄露?不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。
 第二,对象或变量什么时候会被GC回收?这里就涉及java引用的概念。

 强引用,使用最普遍的引用,一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。
 弱引用,JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。可以在缓存中使用弱引用。
 第三,ThreadLocal的内存泄露分析
 ThreadLocal的实现原理,每一个Thread维护一个ThreadLocalMap,key为使用弱引用的ThreadLocal实例,value为线程变量的副本。这些对象之间的引用关系如下,实心箭头表示强引用,空心箭头表示弱引用。

 ThreadLocal 内存泄漏的原因:从上图中可以看出,ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时,Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null,而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉。
但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,这样value永远无法回收,造成内存泄漏。
 什么是强引用,弱引用?
 强引用,使用最普遍的引用,一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。
 弱引用,JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。可以在缓存中使用弱引用。
 为什么要将key设计成ThreadLocal的弱引用?
 如果key使用强引用:ThreadLocal置null时,也就是要被垃圾回收器回收了。因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
 如果key使用弱引用:ThreadLocal置null时,也就是要被垃圾回收器回收了。由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null, 在下一次ThreadLocalMap调用set(), get(), remove()方法的时候会被清除value值。
 总结
由于Thread中包含变量ThreadLocalMap,因此ThreadLocalMap与Thread的生命周期是一样长,如果都没有手动删除对应key,都会导致内存泄漏。但是使用弱引用可以多一层保障:弱引用ThreadLocal不会内存泄漏,对应的value在下一次ThreadLocalMap调用set(),get(),remove()的时候会被清除。因此,ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。
 第四,使用ThreadLocal,如何避免内存泄露?(ThreadLocal正确的使用方法)
 每次使用完ThreadLocal都调用它的remove()方法清除数据;
 扩大变量ThreadLocal的作用域。将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉。

49. 简述一下你对线程池的理解?

线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的有连接池、内存池、对象池。而线程池里面复用的是线程资源。
为什么使用线程池?
 降低资源消耗,通过重复利用已创建的线程降低线程创建和销毁造成的消耗;
 提高响应速度,当任务到达时,无需等待线程创建就立即执行任务;
 提高线程的可管理性,线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以统一分配。

50. 创建线程池的方法?

总结来说线程池的创建可以分为两大类(七种方法):
Executors工厂方法创建,在工具类 Executors 提供了一些静态的工厂方法
new ThreadPoolExecutor方法创建
在这里插入图片描述

51.ThreadPoolExecutor构造函数的重要参数分析

 ThreadPoolExecutor三个比较重要的参数:
 corePoolSize:核心线程数,定义了最小可以同时运行的线程数量。
 maximumPoolSize :线程池中允许存在的最大工作线程数量
 workQueue:存放任务的阻塞队列。新来的任务会先判断当前运行的线程数是否到达核心线程数,如果到达的话,任务就会先放到阻塞队列。
 其他参数:
 keepAliveTime:当线程池中的线程数量大于核心线程数corePoolSize时,如果没有新的任务提交,核心线程外的线程不会立即销毁,而是会等到时间超过keepAliveTime时才会被销毁。
 unit:keepAliveTime 参数的时间单位。
 threadFactory:为线程池提供创建新线程的线程工厂。
 handler:线程池任务队列超过maxinumPoolSize 之后的拒绝策略

52. ThreadPoolExecutor的饱和策略(拒绝策略)

当同时运行的线程数量达到最大线程数量并且阻塞队列也已经放满了任务时,ThreadPoolExecutor会指定一些饱和策略。主要有以下四种类型:
 AbortPolicy:直接抛出异常,拒绝新任务
 CallerRunsPolicy:当线程池无法处理当前任务时,会将该任务交由提交任务的线程来执行。
 DiscardPolicy:直接丢弃新任务。
 DiscardOleddestPolicy:丢弃最早的未处理的任务请求。

53. 创建线程池后,提交任务的流程?

在这里插入图片描述

注意核心线程和非核心线程的区别:
 核心线程:固定线程数,可闲置,默认不被回收。但若ThreadPoolExecutor的allowCoreThreadTimeOut属性设置为true时,keepAliveTime同样会作用于核心线程。
 非核心线程:非核心线程闲置时长超过keepAliveTime时,就会被回收。
总结:如果将allowCoreThreadTimeOut设置为true,那么核心线程和非核心线程就没有区别了。

54. execute()方法和submit()方法的区别?

这个地方首先要知道Runnable接口和Callable接口的区别,之前有写到过 execute()和submit()的区别主要有两点: submit() > execute()
 execute()方法只能执行Runnable类型的任务。submit()方法可以执行Runnable和 Callable类型的任务。
 submit()方法可以返回持有计算结果的Future对象,同时还可以抛出异常,而execute()方法不可以。换句话说就是,execute()方法用于提交不需要返回值的任务,submit()方法用于需要提交返回值的任务。

55. CAS是什么?实现原理是什么?应用场景有哪些?优缺点有哪些?

 概念
CAS(Compare And Swap,比较并交换),通常指的是这样一种原子操作:针对一个变量,首先比较它的内存值与某个期望值是否相同,如果相同,就给它赋一个新值。它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。
 CAS 的逻辑用伪代码描述如下:
if (value == expectedValue) {
value = newValue;
}
 实现原理
CompareAndSwap 是一个native方法,实际上它最终还是会面临同样的问题,就是先从内存地址中读取某变量的值,然后去比较,最后再修改。这个过程不管是在什么层面上实现,都会存在原子性问题。所以呢,CompareAndSwap 的底层实现中,在多核CPU环境下,会增加一个Lock指令对缓存或者总线加锁,从而保证比较并替换这两个指令的原子性。
 应用场景:CAS主要用在并发场景中,比较典型的使用场景有两个。
 第一个是 J.U.C 里面 Atomic 的原子实现,比如 AtomicInteger,AtomicLong。
 第二个是实现多线程对共享资源竞争的互斥性质,比如在AQS、ConcurrentHashMap等都有用到。
 优点:
 由于CAS是非阻塞的,可避免死锁,线程间的互相影响非常小。
 没有锁竞争带来的系统开销,也没有线程间频繁调度的开销。
 缺点:
 可能自旋循环时间过长。如果某个线程通过CAS方式操作某个变量不成功,长时间自旋,则会对CPU带来较大开销。解决方案:限制自旋次数。
 ABA问题。如果某个线程通过方式操作某个变量,期望值是A,但是主存中的A是其他线程操作之后变为B,然后又变成A的,这就等于被修改过,不能进行操作。解决方案:给每个修改加上版本号,由ABA变为1A2B3A,这样就能准确识别。
 只可用来对单个变量进行同步。
以上就是我对这个问题的理解。

56. Atomic原子类

原子操作类是CAS在Java中的应用,从JDK1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。Atomic包里的类基本都是使用Unsafe实现的包装类。 JUC包中的4种原子类:
 基本类型:使用原子的方式更新基本类型
 AtomicInteger:整形原子类
 AtomicLong:长整型原子类
 AtomicBoolean:布尔型原子类
 数组类型:使用原子的方式更新数组里的某个元素
 AtomicIntegerArray:整形数组原子类
 AtomicLongArray:长整形数组原子类
 AtomicReferenceArray:引用类型数组原子类
 引用类型:
 AtomicReference:引用类型原子类,存在ABA问题
 AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。
 AtomicMarkableReference:原子更新带有标记位的引用类型
 原子更新字段类
 AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
 AtomicLongFieldUpdater:原子更新长整型字段的更新器。 AtomicReferenceFieldUpdater:引用类型更新器原子类

57. AQS是什么?实现原理是什么?工作流程是什么?

 概念
AQS是AbstractQueuedSynchronizer的简称,即抽象队列同步器,从字面意思上理解:
 抽象:它是一个抽象类,只实现一些主要逻辑,有些方法需要子类去实现;
 队列:使用先进先出队列存储数据;
 同步:实现了同步的功能。
那AQS有什么用呢?
AQS 它是 J.U.C 这个包里面非常核心的一个抽象类,它为多线程访问共享资源提供了一个队列同步器。在 J.U.C 这个包里面,很多组件都依赖 AQS 实现线程的同步和唤醒,如Lock、ReentrantLock、FutureTask等等皆是基于AQS的。
 实现原理(核心):简单来说,AQS就是维护了一个共享资源,然后使用队列来保证线程排队获取资源的一个过程。

 AQS的工作流程
多个线程通过对这个 state 共享变量进行修改来实现竞态条件,竞争失败的线程加入到FIFO队列并且阻塞,抢占到竞态资源的线程释放之后,后续的线程按照FIFO顺序实现有序唤醒。
 资源共享模式
资源有两种共享模式,或者说两种同步方式:
 独占模式(Exclusive)︰资源是独占的,同一时刻只能一个线程获取,如ReentrantLock。
 共享模式(Share):同时可以被多个线程获取,具体的资源个数可以通过参数指定。如Semaphore/CountDownLatch。
总结
从本质上来说,AQS 提供了两种锁机制,分别是排它锁,和共享锁。

58. AQS 为什么要采用双向链表结构?

首先,双向链表的特点是它有两个指针,一个指针指向前驱节点,一个指针指向后继节点。所以,双向链表可以支持常量 O(1) 时间复杂度的情况下找到前驱结点,所以在插入和删除操作的时候,要比单向链表简单、高效。因此,从双向链表的特性来看,我认为 AQS 使用双向链表有三个方面的考虑。
 第一个方面
没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,为了避免链表中存在异常线程导致无法唤醒后续线程的问题。所以线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从 head 节点开始遍历,性能非常低。
 第二个方面
在 Lock 接口里面有一个lockInterruptibly()方法,表示处于阻塞状态(没有竞争到锁)的线程允许被外部通过interrupt()方法中断。此时线程被标记为 CANCELLED 状态,是不需要去竞争锁的,但是它仍然存在于双向链表里面。这意味着在后续的锁竞争中,需要移除该线程,否则会导致锁阻塞的线程无法被正常唤醒。在这种情况下,如果是单向链表,就需要从 Head 节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。
 第三个方面
为了避免线程阻塞和唤醒的开销,所有刚加入到链表的线程,首先会通过自旋的方式尝试去竞争锁。但是实际上按照公平锁的设计,只有头节点的下一个节点才有必要去竞争锁,后续的节点竞争锁的意义不大。 否则,就会造成羊群效应,也就是大量的线程在阻塞之前尝试去竞争锁带来比较大的性能开销。 所以,为了避免这个问题,加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是不是头节点,如果不是头节点,就没必要再去触发锁竞争的动作。所以这里会涉及到前置节点的查找,如果是单向链表,那么这个功能的实现会非常复杂。

  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值