JUC面试指南,并发编程

写在前面:
先声明下,这个面试专题,主要是写给自己的,用来在挤公交的时候学习下,顺便做个分享。。。 我就是个小菜鸡。

JUC并发编程

  • JUC
    • JMM(Java内存模型)
      • Volatile
      • 内存屏障的种类:(偏,字节考过。。。)
    • Synchronized
    • Lock
    • 等待-通知机制
    • Thread的生命周期
      • 线程的数量
    • 线程池
      • 线程池的参数:
      • 线程池的种类:
    • 各种锁
      • 乐观锁和悲观锁
      • CAS实现原理
      • 可重入锁 ReentrantLock
      • 公平锁与非公平锁
      • 公平锁与非公平锁
      • 自旋锁vs适应性自旋锁
      • 读写锁
    • ThreadLocal
](JUC并发编程)

JUC

JMM(Java内存模型)

为了解决CPU的高速运算和内存的读取效率的差异,在CPU中加入了高速缓存。且这些缓存具有缓存一致性。
描述的一种规则或规范,通过这组规范定义了程序中各个变量的访问方式;

有关于同步的规定:
线程解锁前,必须把共享变量的值刷新回主内存;
线程加锁前,必须读取主内存的最新值到自己的工作内存;
加锁解锁是同一把锁;

JUC面试指南,并发编程_第1张图片

1.程序以及数据被加载到主内存
2.指令和数据被加载到CPU的高速缓存
3.CPU执行指令,把结果写到高速缓存
4.高速缓存中的数据写回主内存

Volatile

作用:保证了可见性和有序性,但是不保证原子性;
所谓原子性:即不可分割,完整性,当某个线程正在做业务时,不可以加塞或者分割。表现形式:当多线程被volatile锁定的i被 执行i++共1000次时,结果不一定为1000;可能会变小;

流程
在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令

  1. 将当前处理器缓存行的数据写回系统内存;
  2. 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效
  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

保证有序性:通过内存屏障来保证的,它保证了特定操作的执行顺序,保证了某些变量内存有序性。

内存屏障的种类:(偏,字节考过。。。)

  1. LoadLoad 屏障
    序列:Load1,Loadload,Load2
    确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

  2. StoreStore 屏障
    序列:Store1,StoreStore,Store2
    确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。

  3. LoadStore 屏障
    序列: Load1; LoadStore; Store2
    确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

  4. StoreLoad Barriers
    序列: Store1; StoreLoad; Load2
    确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令 不正确的使用了Store1的数据,而不是另一个处理器在相同内存位置写入一个新数据。正因为如此,所以在下面所讨论的处理器为了在屏障前读取同样内存位置存过的数据,必须使用一个StoreLoad屏障将存储指令和后续的加载指令分开。Storeload屏障在几乎所有的现代多处理器中都需要使用,但通常它的开销也是最昂贵的。它们昂贵的部分原因是它们必须关闭通常的略过缓存直接从写缓冲区读取数据的机制。这可能通过让一个缓冲区进行充分刷新(flush),以及其他延迟的方式来实现。

Synchronized

主要是利用了一个monitor的监视器来实现的。
任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中的线程就会有机会重新获取该监视器。
使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。

Lock

内部主要是AQS的队列同步器
有一个state标志位:1代表有线程占用;
里面有同步队列,用来存放其他线程;当需要特定条件时会进入等待队列;当满足了条件后就回到同步队列

等待-通知机制

wait,notify和notifyAll都是Object类里面的;
sleep是Thread类中的。

Thread的生命周期

JUC面试指南,并发编程_第2张图片
结合图片很好理解:主要可以分为4大类:New初始状态,Runnable(可运行状态),休眠状态和Terminate(终止状态);

线程的数量

  • CPU密集型(都是计算,不需要读取数据,例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,便是属于CPU bound的程序)
    线程数量 = CPU核数 + 1
    不需要切换线程,+1是为了防止超频

  • IO密集型
    最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
    实际使用:CPU* 2或者3 ,进行压力测试

线程池

线程池的参数:

核心线程数,缓冲(阻塞)队列,最大线程数,工厂方法,空闲时间,时间单位,拒绝策略

系统会先启动指定核心线程数的线程,然后开始工作,当核心线程数不够时,将任务放入缓冲队列,当缓冲队列满时,新建线程,直道达到最大线程数后,采取相应的拒绝策略; 工厂方法是用来生成线程的方法,一般默认; 当线程空闲一段时间后,会消失。

缓冲队列有:ArrayBlockQueue(有界),LinkedBlockQueue(无界),SynchronousQueue(同步队列)
拒绝策略:抛出异常;提交任务的线程直接执行任务;丢弃当前任务;丢弃最早的任务

线程池的种类:

主要是4类:

  1. newFixedThreadPool:固定线程数,采用LInkedBlockingQueue,适用于任务数量不均匀的场景
  2. newCachedThreadPool:利用SynchronousQueue,适用于低延迟的短期任务
  3. newSingleThreadPool:单个固定的线程池,适用于保证异步执行顺序的场景
  4. newScheduledThreadPool:适用于定期执行任务的场景

各种锁

乐观锁和悲观锁

乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。

先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的

CAS实现原理

(CAS,全称是 Compare And Swap,即“比较并交换”)。CAS 指令包含 3 个参数:共享变量的内存地址 A、用于比较的值 B 和共享变量的新值 C;并且只有当内存中地址 A 处的值等于 B时,才能将内存中地址 A 处的值更新为新值C
原子意味着尝试更改相同AtomicReference的多个线程(例如,使用比较和交换操作)不会使AtomicReference最终达到不一致的状态。

存在ABA问题,即A值经过了其他线程的调用后,值仍旧没变,类似于+1后又-1;就不能保证同步性了
解决:通过添加时间(AtomicLong)或者版本号(AtomicStampedReference)等原子操作来实现

可重入锁 ReentrantLock

线程可以重复获取同一把锁,当一个线程执行到某个Synchronized方法时,比如说method1,而在method1中会调用另外一个Synchronized方法method2,此时线程不必重新区申请锁,而是直接执行方法method2(Synchronized也是可重入锁)

公平锁与非公平锁

ReentrantLock 这个类有两个构造函数,一个是无参
构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true
就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。

加锁的流程

公平锁与非公平锁

ReentrantLock 这个类有两个构造函数,一个是无参
构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true
就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。

自旋锁vs适应性自旋锁

在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。

适应性自旋锁:
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

读写锁

适用于非常普遍的并发场景:读多写少场景

读写锁基本原则:

  1. 允许多个线程同时读共享变量;
  2. 只允许一个线程写共享变量;
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量

ThreadLocal

ThreadLocal 提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal 相当于提供了一种线程隔离,将变量与线程相绑定。
ThreadLocal的作用和原理。
作用:要编写一个多线程安全(Thread-safe)的程序是困难的,为了让线程共享资源,必须小心地对共享资源进行同步,同步带来一定的效能延迟,而另一方面,在处理同步的时候,又要注意对象的锁定与释放,避免产生死结,种种因素都使得编写多线程程序变得困难。

尝试从另一个角度来思考多线程共享资源的问题,既然共享资源这么困难,那么就干脆不要共享,何不为每个线程创造一个资源的复本。将每一个线程存取数据的行为加以隔离,实现的方法就是给予每个线程一个特定空间来保管该线程所独享的资源。

ThreadLocal的原理:ThreadLocal是如何做到为每一个线程维护变量的副本的呢?其实实现的思路很简单,在ThreadLocal类中有一个Map,用于存储每一个线程的变量的副本。

还可以看一下happens-before规则,太多了,我是记不住,要是面试,就大概讲解下吧。。。类似于内存屏障的功能。

你可能感兴趣的:(面试)