⭐乐观锁与悲观锁

乐观锁与悲观锁的区别?

  • 悲观锁:共享资源每次只给一个线程使用,其他线程阻塞,用完之后再把资源让给其他资源。但是读写锁可以多个线程同时读。
  • 乐观锁:共享资源每次可以给多个线程使用,只是再提交修改的时候去验证资源是否被其他线程所修改。

使用场景

  • 悲观锁:适合写多读少的情况。
  • 乐观锁:适合读多写少的情况。

缺陷

  • 悲观锁:高并发下,激烈的锁竞争会造成线程阻塞,大量的线程会导致系统上下文切换频繁,增加系统的性能开销;还有可能导致死锁。
  • 乐观锁:高并发下,相比悲观锁来所,不存在锁竞争造成线程阻塞,也不会有死锁问题,性能往往更好。但是再写冲突高的场景下可能会导致ABA问题,性能忽高忽低等

CAS了解吗?原理是什么?

乐观锁一般会使用版本号机制或者CAS算法实现,CAS算法使用会多一些。值得一提的是,这里的版本号机制和CAS算法中的ABA问题的解决方案是同一个东西。

CAS算法的思想很简单,就是用一个预期值和要更新的变量值进行比较,两个值相等才会进行更新。这里的比较和修改两个步骤再CPU硬件层面是一条原子指令,是不可拆分的,这也是CAS算法的基础。

比如ConcurrentHashMap采用的就是CAS和synchronized来保证并发安全的。
原子类也是

⭐JMM(Java memory model)

并发编程的三个重要特性

  • 原子性:一个或者多个操作要么全部执行且不中断,要么全部不执行。
  • 可见性:当一个线程修改了共享变量,其他线程能够立刻看到这个修改
  • 有序性:编译器优化和指令重排在多线程情况下,可能导致意外的结果。因此保证有序性可以禁止指令进行重排序优化。

实现手段

  • 原子性:一般都是通过加锁去实现,当然也可以使用CAS实现。
  • 可见性:
    • 使用volatile关键字
    • synchronizedlock同步块
    • final关键字再构造函数中有正确初始化(没有this逃逸)
  • 有序性:
    • volatile
    • synchronizedlock同步块
    • happens-before原则:在多线程环境中,当两个操作之间存在 happens-before 关系时,前一个操作的修改对后一个操作是可见的。

什么是JMM?为什么需要JMM?

要了解JMM,就要了解计算机内存的发展史。

  • 第一阶段:cpu从磁盘中读取数据,但是读取的速度太慢,使其cpu的性能大大浪费;
  • 第二阶段:再cpu和磁盘中间增加了内存(主存),用以加快读取过程,但此时cpu的性能依旧没有得到充分的利用;
  • 第三阶段:给cpu里面增加了缓存(一级、二级、三级),使其读取速度非常高。但是带来了缓存一致性的问题。

了解缓存一致性问题:

  • 情况一:一个线程修改了缓存但是没有同步到主存,其他的cpu核心再去读取数据就是读取的脏数据,这也是可见性的本质。
  • 情况二:再执行代码的时候,cpu或者编译器可能会对指令进行重新排序,导致执行的顺序和代码的顺序不一致。这只能保证单线程串行执行的语义一致,但是没有办法保证多线程并发执行的语义一致,这也是有序性的本质。
  • 情况三:线程A读取到的库存为1,要进行减1的操作,但是只是读取到了还没执行,cpu时间片用完了。此时线程B拿到库存减1,将库存边为了0。当线程A回来执行的时候,再减1,此时的库存为-1。不符合预期,库存不能为负数,这也是原子性的本质。

所以整个并发编程的核心就是要去解决可见性、有序性、原子性问题。操作系统为了去解决这些问题,定义了一些规范。但是不同的操作模型内存模型是不同的。如果Java直接服用操作模型的内存模型去解决这些问题,就可能会导致同一套代码在不同的系统上可能运行结果不一样。但是Java的口号就是一次编译处处运行,所以Java就自己去提供了一套内存模型去屏蔽不同操作系统的差异。

JMM是如何抽象线程和主内存之间的关系?

知道了为什么需要JMM,那JMM是如何去解决这些问题的呢?JMM定义了一套并发编程相关的规范,抽离了缓存、线程、主存之间的关系去解决这些问题。

具体抽象过程:

    ┌─────────────┐                ┌─────────────┐
    │   线程1     │                │   线程2     │
    │ ┌─────────┐ │                │ ┌─────────┐ │
    │ │本地内存 │ │                │ │本地内存 │ │
    │ │  ┌────┐ │ │                │ │  ┌────┐ │ │
    │ │  │共享│ │ │                │ │  │共享│ │ │
    │ │  │变量│ │ │                │ │  │变量│ │ │
    │ │  │副本│ │ │                │ │  │副本│ │ │
    │ │  └────┘ │ │                │ │  └────┘ │ │
    │ └─────────┘ │                │ └─────────┘ │
    └──────┬──────┘                └──────┬──────┘
           │                              │
           └────────────┬─────────────────┘
                        │
             ┌──────────▼──────────┐
             │       主内存         │
             │ ┌──────┬──────┬──────┐
             │ │共享  │共享  │共享  │
             │ │变量1 │变量2 │变量3 │
             │ └──────┴──────┴──────┘
             └─────────────────────┘
  • 将寄存器、cpu缓存这些东西统一抽象为线程的工作内存(本地内存),这是线程私有的一个计算区域,每个线程的工作内存相互独立。线程必须把主存中的数据读取到自己的工作内存中去计算,要与其他线程共享数据时,必须将计算后的结果刷新回主存中,其他线程再去读取。
  • 抽象完成之后,就需要去定义工作内存和主内存之间要怎么进行交互。JMM规定了八种主存和工作内存之间的交互方式,如lockunlockread等。
  • 虽然定义了交互的方式,但是没有定义什么时候做,具体怎么实现顺序的保障。所以JMM需要通过内存屏障去保证交互的顺序性和可见性。这是一种cpu指令,主要有四种,都是为了禁止重排序。
  • 但是从程序员写代码的角度来看,还是不知道编译器和处理器是怎么重排序的,怎么优化的。不知道实际的执行顺序就不知道线程A做的操作,线程B是否可见。因此JMM规定了happens-before规则(有八条),这个一个可见性的逻辑抽象,它不管底层是怎么实现的,只管 A happens before B,那么A的结果对B一定是可见的。

在知道happens-before规则时候,只需要知道操作满足规则,那就有顺序性的保证,那就是可见的。否则JVM就可以对指令进行重排序。

synchronized是什么?怎么使用?

synchronized是个锁,使这段代码同一时间只能被一个线程访问,这就是原子性。它编译之后就是monitor enter 和 monitor exit 两个指令。enter就是加锁,加锁的时候会使用读屏障,强行从主存中重新读取数据。exit是解锁,解锁的时候回使用写屏障,强制将cpu缓存中的变量刷新到主存中。这就是保证了可见性,同时也通过内存屏障防止指令重排,这就是有序性

如何使用:

  • 它修饰普通方法锁就是this,就是当前对象;
  • 修饰静态方法,锁是类.class对象;
  • 代码块,括号里面写什么它就锁什么;

什么是锁升级

要了解锁升级,就需要了解jdk1.6之前的synchronized的逻辑:

  • 加锁本质上是调用操作系统底层原语mutex;
  • 同时又涉及到线程的阻塞和唤醒;
  • 但是Java线程是一对一的模型,每一个线程都直接对应一个操作系统的内核级线程,每次阻塞切换线程都需要操作系统从用户态切换到内核态,开销很大。

然而很多系统大多数时间并没有锁竞争,只在特定的时间并发量大,比如周末、618等。因此为了节约资源,低峰时期可以无锁,高峰时期为了应对锁竞争越来越激烈过程,从无锁升级到偏向锁、轻量级锁、重量级锁。

具体优化过程:

  • 无锁:当只有一个线程来访问代码块时,JVM将对象头的Mark Word 锁标志位设置为偏向锁,然后将线程id记录到mark word里面,这个时候这个线程进入这个同步代码块就不需要其他同步操作了;
  • 偏向锁:针对的是只有一个线程来抢锁的情况,当又第二个线程来抢锁的时候,就升级为轻量级锁;
  • 轻量级锁:第二个抢锁的线程拿不到锁,就会采用cas加自旋的方式去获取锁。这种情况是属于锁不多占用时间也不长的情况,可以避免阻塞导致的用户态和内核态的切换产生的开销;
  • 重量级锁:当第二个抢锁的线程自选到一定次数或者又更多的线程来抢锁,就会升级到重量级锁。此时对象头的Mark Word 的指针就会指向锁监视器monitor(负责记录锁的拥有者,锁的重入次数,负责线程的阻塞和唤醒)

⭐ThreadLocal

ThreadLocal有什么用?

**ThreadLocal 是 Java 中实现线程局部存储的工具:**每个线程都拥有自己独立的变量副本,线程之间互不干扰。简单说,同一个 ThreadLocal 变量在不同线程中存取的是不同的值

ThreadLocal的原理了解吗?

  • 存储方式:每一个线程里面有一个ThreadLocalMap,本质是一个hashmap,底层使用Entry数组来存储数据。其中entry的key是一个ThreadLocal的弱引用,value就是资源。
  • key内存泄漏:在使用线程池的情况下,线程会一直被复用。这会导致ThreadLocal的强引用不在了,但是其他的引用链还是存在的,这也是为什么entry的key要设计为弱引用,这样gc回收的时候,key就能被垃圾回收,从而避免内存泄漏。
  • key回收:只剩下entry的key的弱引用,没有其他强引用的时候,gc才能回收key。
  • value清洗:当我们调用get或者set方法时候,就会遍历entry数组,清理掉key为null的entry。
  • value强引用:如果value是弱引用的话,gc的时候没有强引用会导致被垃圾回收,导致处理业务过程中数据丢失。
  • value内存泄漏:因此使用完之后,必须手动断开value的强引用(remove),然后让gc去回收掉。
  • threadLocal和锁:都是为了解决并发安全;一个是访问同一个共享资源,另一个是复制资源各不干扰。

⭐线程池

为什么不推荐使用内置线程池?

队列的长度、线程的数量默认太大,可能会导致OOM

线程池常见的参数有那些?

参数名 类型 作用
corePoolSize int 核心线程数
maximumPoolSize int 最大线程数
keepAliveTime long 非核心线程空闲存活时间
unit TimeUnit keepAliveTime 的时间单位
workQueue BlockingQueue<Runnable> 任务阻塞队列
threadFactory ThreadFactory 线程工厂(用于创建线程,可自定义名称、守护状态等)
handler RejectedExecutionHandler 拒绝策略(当队列满且线程数达到最大值时执行)

拒绝策略有哪些?

策略类 行为 适用场景
AbortPolicy(默认) 直接抛出 RejectedExecutionException 运行时异常 关键业务,不允许丢弃任务,需要上游感知并处理
CallerRunsPolicy 由提交任务的线程(调用者)自己执行该任务 降低新任务提交速度,避免系统过载,适合对延迟不敏感、允许降级的场景
DiscardPolicy 静默丢弃新提交的任务,不抛异常 非核心、可丢失的任务(如日志、埋点)
DiscardOldestPolicy 丢弃队列头部(最旧)的未处理任务,然后重新尝试提交当前任务 类似 DiscardPolicy,但优先保留新任务

⭐AQS

AQS是什么?AQS原理是什么?

AQS 是 AbstractQueuedSynchronizer 的缩写,是 Java 并发包(java.util.concurrent)中几乎所有锁和同步器的底层基础框架ReentrantLockCountDownLatchSemaphoreReentrantReadWriteLock 等都是基于 AQS 实现的。

原理:

  • state:
    • 其值为0,表示资源空闲;其值为1,表示资源被占有;这是ReentrantLock的思想。
    • 可是就两个值为什么不设计为Boolean?那是因为在共享资源可以同时被n个线程访问的情况下,此时state的值为n,这就是semaphore的设计思想。
    • 递归场景下,当前线程多次访问共享资源,访问一次state就要+1,释放一次就要-1,当state为0代表资源空闲。这是reentrantLock可重入的设计思想。
    • 由于是多线程下,要保证可见性,所以该状态变量加上了volatile去保证可见性。
    • 那原子性如何保证呢?这里使用的是cas去修改,来保证原子性。但是cas一直失败,就会阻塞放入队列FIFO中,等待被唤醒。
  • tryAcquire:用于实现具体的获取共享资源的方式,AQS只需要知道有没有获取成功。
  • acquire:会调用tryAcquire去获取资源,获取不到就阻塞。
  • 公平性:FIFO队列为实现公平性提供了基础,实现类也可以自定义实现tryAcquire方法去自定义公平和非公平策略;
    • ReentrantLock就是这么做的,它有一个FairSync和NonFairSync的子类,就是实现的tryAcquire方法。
  • 共享和独占:
    • 共享:多个线程共享资源,比如semaphore或者CountDonwLatch;
    • 独占:只允许一个线程独占资源,对应AQS的acquire和tryAcquire,在reentrantlock里的表现就是lock和trylock。
  • condition:用于管理调用了await的线程,将其放入条件队列中;