【檀越剑指大厂--并发编程】并发编程总结

并发编程

一.并发基础

1.什么是并行和并发?

并行,表示两个线程同时(同一时间)做事情。
并发,表示一会做这个事情,一会做另一个事情,存在着调度。

单核 CPU 不可能存在并行(微观上)。

2.什么是活锁?

假设有两个线程 1、2,它们都需要资源 A/B,假设 1 号线程占有了 A 资源,2 号线程占有了 B 资源;由于两个线程都需要同时拥有这两个资源才可以工作,为了避免死锁,1 号线程释放了 A 资源占有锁,2 号线程释放了 B 资源占有锁;此时 AB 空闲,两个线程又同时抢锁,再次出现上述情况,此时发生了活锁。简单类比,电梯遇到人,一个进的一个出的,对面占路,两个人同时往一个方向让路,来回重复,还是堵着路。如果线上应用遇到了活锁问题,恭喜你中奖了,这类问题比较难排查。

3.单线程创建方式

单线程创建方式比较简单,一般只有两种方式:继承 Thread 类和实现 Runnable 接口;这两种方式比较常用就不在 Demo 了,但是对于新手需要注意的问题有:

  • 不管是继承 Thread 类还是实现 Runable 接口,业务逻辑写在 run 方法里面,线程启动的时候是执行 start()方法;
  • 开启新的线程,不影响主线程的代码执行顺序也不会阻塞主线程的执行;
  • 新的线程和主线程的代码执行顺序是不能够保证先后的;
  • 对于多线程程序,从微观上来讲某一时刻只有一个线程在工作,多线程目的是让 CPU 忙起来;
  • 通过查看 Thread 的源码可以看到,Thread 类是实现了 Runnable 接口的,所以这两种本质上来讲是一个;
4.终止线程运行的情况?

线程调度器选择优先级最高的线程运行。但是,如果发生以下情况,就会终止线程的运行:

  • 线程体中调用了 yield()方法,让出了对 CPU 的占用权;
  • 线程体中调用了 sleep()方法,使线程进入睡眠状态;
  • 线程由于 I/O 操作而受阻塞;
  • 另一个更高优先级的线程出现;
  • 在支持时间片的系统中,该线程的时间片用完。
5.如何减少上下文切换

减少上下文切换的方法有无锁并发编程、CAS 算法、使用最少线程和使用协程。

  • 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
  • CAS 算法。Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多多线程来处理,这样会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
6.上下文切换如何检测?

监测:

使用 Lmbench3 可以测量上下文切换的时长;

使用 vmstat 可以测量上下文切换的次数

image-20220414180913443

排查步骤:

1.查询 Java 程序的进程 ID,使用 jstack 命令 dump 出线程文件

sudo -u admin/opt/ifeve/java/bin/jstack31177>/home/t engfei.fangtf/dump17

2.统计线程状态

[tengfei.fangtf@ifeve~]$grep java.lang.ThreadState dump17|awk '{print $2$3$4$5}
| sort | uniq -c
39 RUNNABLE
21 TIMED_WAITING(onobjectmonitor)
6 TIMED_WAITING(parking)
51 TIMED_WAITING(sleeping)
305 WAITING(onobiectmonitor)
3 WAITING(parking)

3.查看 WAITING 状态的线程在干什么?

"http-0.0.0.0-7001-97" daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in
Objectwait()[0x0000000052423000]
java.lang.Thread.State: WAITING(on object monitor) at java.lang.Objectwait(Native Method)
-waiting on <0x00000007969b2280>(a orapachetomcat.utinetAprEndpoint$Worker at java.lang.Objectwait(Objectjava:485)
at org.apache.tomcat.util.netAprEndpoint$Workerawait(AprEndpointjava:1464
-locked <0x00000007969b2280>(a org.apache.tomcatutilnetAprEndpoint$Worker) at org.apache.tomcat.util.net.AprEndpoint$Worker.run(AprEndpointjava:1489) at java.lang.Thread.run(Threadjava:662)

4.减少 JBOSS 工作线程数

<maxThreads="250" maxHttpHeaderSize="8192" emptySessionPath="false"minSpareThreads="40" max SpareThreads="75" maxPostSize="512000"protocol="HTTP/1.1" enableLookups="false"redirectPort="8443" acceptCour nt="200" bufferSize="16384"
connectionTimeout="15000"disableUploadTimeout="fal se" useBodyEncodingForURI= "true">
7.什么是死锁?

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去

如上图例子:线程 A 己经持有了资源 2,它同时还想申请资源 1,线程 B 已经持有了资源 1,它同时还想申请资源 2,所以线程 1 和线程 2 就因为相互等待对方已经持有的资源,而进入了死锁状态。

8.写出死锁代码?

死锁的产生必须具备以下四个条件。

  • 互斥条件:一个资源同时只能有一个线程占有.其他线程只能等待.
  • 请求并持有条件:当前线程已经获取到一个资源,又获取其他资源,其他资源被别的线程占有,当前线程等待,但是不释放持有资源.
  • 不可剥夺条件:占有资源期间,不能被其他线程剥夺,只能自己释放.
  • 环路等待条件:等待资源形成环形链.a 被 A 占有,b 被 B 占有,A 想获取 b,B 想获取 a
public class DeadLockDemo {
  private static String A = "A";
  private static String B = "B";

  public static void main(String[] args){
    new DeadLockDemo().deadLock();
  }

  private void deadLock(){
    Thread t1 = new Thread(new Runnable(){
      @Override
      public void run(){
        //线程1获取A的锁
        synchronized (A){
          try {
            Thread.currentThread().sleep(2000);
          } catch (InterruptedException e){
            e.printStackTrace();
          }
          synchronized (B){
            System.out.println("1");
          }
        }
      }
    });
    Thread t2 = new Thread(new Runnable(){
      @Override
      public void run(){
        //线程2获取B的锁
        synchronized (B){
          //A对象已经被线程1持有
          synchronized (A){
            System.out.println("2");
          }
        }
      }
    });
    t1.start();
    t2.start();
  }
}
9.如何避免死锁呢?

要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,目前只有请求并持有条件和环路等待条件是可以被破坏的。

  • 避免一个线程同时获取多个锁。
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
  • 尝试使用定时锁,使用 locktryLock(timeou t)来替代使用内部锁机制。
  • 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

如上题代码中,在线程 B 中获取资源的顺序和在线程 A 中获取资源的顺序保持一致,其实资源分配有序性就是指,假如线程 A 和线程 B 都需要资源 1,2,3,…, n 时,对资源进行排序,线程 A 和线程 B 只有在获取了资源 n-1 时才能去获取资源 n.

public class DeadLockRelessDemo {
  private static String A = "A";
  private static String B = "B";

  public static void main(String[] args){
    new DeadLockRelessDemo().deadLock();
  }

  private void deadLock(){
    Thread t1 = new Thread(new Runnable(){
      @Override
      public void run(){
        //线程1获取A的锁
        synchronized (A){
          try {
            Thread.currentThread().sleep(2000);
          } catch (InterruptedException e){
            e.printStackTrace();
          }
          synchronized (B){
            System.out.println("1");
          }
        }
      }
    });
    Thread t2 = new Thread(new Runnable(){
      @Override
      public void run(){
        //线程2也获取A的锁
        synchronized (A){
          synchronized (B){
            System.out.println("2");
          }
        }
      }
    });
    t1.start();
    t2.start();
  }
}
10.线程,进程,协程的区别?

进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位.

线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。操作系统在分配资源时是把资源分配给进程的,但是 CPU 资源比较特殊,它是被分配到线程的,因为真正要占用 CPU 运行的是线程,所以也说线程是 CPU 分配的基本单位。在 Java 中,当我们启动 main 函数时其实就启动了一个 JVM 进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程

一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。

进程:静态分配内存资源的最小单位

线程:动态执行任务的最小单位

协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。协程也叫纤程.

线程是在内核态调用的,协程在用户态调用,避免了上下文切换

11.线程的状态?
状态名称说明
NEW初始状态,线程被构建,但是还没有调用 start 方法
RUNNABLE运行状态,Java 线程将操作系统中的就绪和运行两种状态笼统地称作“运行中”
BLOCKED阻塞状态,表示线程阻塞于锁
WAITING等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME WAITING超时等待状态,该状态不同于 WAITING,它是可以在指定的时间自行返回的
TERMINATED终止状态,表示当前线程已经执行完毕
  • NEW :创建了线程对象但尚未调用 start()方法时的状态。
  • RUNNABLE:线程对象调用 start()方法后,线程处于可运行状态,此时线程等待获取 CPU 执行权。
  • BLOCKED:线程等待获取锁时的状态。
  • WAITING:线程处于等待状态,处于该状态标识当前线程需要等待其他线程做出一些特定的操作唤醒自己。
  • TIME_WAITING:超时等待状态,与 WAITING 不同,在等待指定的时间后会自行返回。
  • TERMINATED:终止状态,表示当前线程已执行完毕。
12.线程状态变迁?
  • RUNNABLE 有 2 种状态,一种是 running,调用 yield 变为 ready,一种是 ready,调用 run 为 running
  • New 状态为初始状态,New Thread 的时候是 new 这种状态,调用 start 方法后,变为 runnable
  • Terminated 任务执行完成后线程的状态为终止
  • runnable 状态到 waiting 状态
    • wait()
    • join() 不需要主动唤醒
    • park()
  • waiting 状态到 runnable 状态
    • 唤醒
    • notify()
    • notifyAll()
    • unpark(thread)
  • runnable 状态到 timed_waiting 状态
    • sleep(long)
    • wait(long)
    • join(long) 不需要主动唤醒
    • parkNanos()
    • parkUntil()
  • timed_waiting 状态到 runnable 状态
    • notify()
    • notifyAll()
    • unpark(thread)
  • blocked 到 runnable
    • 获取到锁
  • runnable 到 blocked
    • synchronized 方法
    • synchronized 块

image-20220609152528859

13.CPU 术语定义?
术语英文单词描述
内存屏障memory barriers是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行cache line缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期
原子操作atomic operations不可中断的一个或一系列操作
缓存行填充cache line fill当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1L2.L3 的或所有)
缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写命中write hit当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中
写缺失write misses the cache一个有效的缓存行被写人到不存在的内存区域
14.java 内存模型?

Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。

JMM 关于同步的规定:

  1. 线程解锁前,必须把共亨变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

image-20220415132141792

15.什么是 Daemon 线程?

Daemon 线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的时候,Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置为 Daemon 线程。注意 Daemon 属性需要在启动线程之前设置,不能在启动线程之后设置。Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 fnally 块并不一定会执行.

public class Daemon{
  public static void main(String[] args){
    Thread thread=new Thread(new DaemonRunner()"DaemonRunner");
    thread.setDaemon(true);
    thread.start();
  }
  static class DaemonRunner implements Runnable{
    @Override
    public void run() {
      try{
        SleepUtilssecond(10);
      } finally{
        System.out.println("DaemonThread finally run.");
      }
    }
  }
}
16.说说三个中断方法?

说说 interrupt(),interrupted(),isInterrupted()的区别

  • void interrupt()方法:中断线程,例如,当线程 A 运行时,线程 B 可以调用钱程 A 的 interrupt()方法来设置线程 A 的中断标志为 true 并立即返回.仅仅是设置标志,线程 A 实际并没有被中断,它会继续往下执行.如果线程 A 因为调用了 wait 系列函数、join 方法或者 sleep 方法而被阻塞挂起,这时候若线程 B 调用线程 A 的 interrupt()方法,线程 A 会在调用这些方法的地方抛出 InterruptedException 异常而返回。
  • boolean isinterrupted()方法:检测当前线程是否被中断,如果是返回 true,否则返回 false.
  • boolean interrupted()方法:检测当前线程是否被中断,如果是返回 true,否则返回 false.与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志,并且该方法是 static 方法,可以通过 Thread 类直接调用.另外从下面的代码可以知道,在 interrupted()内部是获取当前调用线程的中断标志而不是调用 interrupt 方法的实例对象的中断标志。
  • Java 中断机制是一种协作机制,中断只是给线程打一个中断标记,具体如何操作还要看线程自己 interrupt()函数作用仅仅是为线程打一个中断标记。interrupted()与 isInterrupted()函数,都是返回线程的中断状态,但是 interrupted()被 static 修饰,返回当前线程的中断状态,并且会清除线程的中断标记;而 isInterrupted()未 static 修饰,被 Thread 对象调用,它不会清除线程的中断标记。

从代码中可以看出,interrupted 方法调用的 currentThread()的 native 方法,而 isInterrupted 方法调用的实例对象的 native 方法.Native 方法传参:true 代表清除中断标志,false 代表不清除中断标志

public void interrupt(){
  if (this != Thread.currentThread())
    checkAccess();

  synchronized (blockerLock){
    Interruptible b = blocker;
    if (b != null){
      interrupt0(); //Just to set the interrupt flag
      b.interrupt(this);
      return;
    }
  }
  interrupted();
}

public static boolean interrupted(){
  return currentThread().isInterrupted(true);
}

public boolean isInterrupted(){
  return isInterrupted(false);
}

private native boolean isInterrupted(boolean ClearInterrupted);
17.说说 join 方法和 yeild 方法?

在项目实践中经常会遇到一个场景,就是需要等待某几件事情完成后才能继续往下执行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理.Thread 类中有一个 join 方法就可以做这个事情,join 方法是 Thread 类直接提供的.join 是无参且返回值为 void 的方法。

Thread 类中有一个静态的 yield 方法,当一个线程调用 yield 方法时,实际就是在暗示线程调度器当前线程请求让出自己的 CPU 使用.我们知道操作系统是为每个线程分配一个时间片来占有 CPU 的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了 Thread 类的静态方法 yield 时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。

当一个线程调用 yield 方法时,当前线程会让出 CPU 使用权,然后处于就绪状态,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出 CPU 的那个线程来获取 CPU 执行权。

join 方法可中断响应,释放 CPU,释放的时当前调用 join 方法对象的锁.join 方法会被隐式唤醒

18.说说 sleep 和 yeild 和 wait 区别?

sleep 方法与 yeild 方法的区别在于,当线程调用 sleep 方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。不会释放资源锁,只是让出 CPU 的时间片.线程会阻塞.

而调用 yield 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。

wait 方法会阻塞,会释放锁,会让出 CPU 的使用权,且不会参与锁竞争,除非被唤醒后才参与竞争.

sleep 方法不释放锁,释放 cpu,可响应中断.先清除中断标志,再打印异常

19.说说 notify 和 wait 方法?

一个线程调用共享对象的 notify()方法后,会唤醒一个在该共享变量上调用 wait()方法后被挂起的线程。此外,被唤醒的线程不能马上从 wait 方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回(调用 wait 方法后,会释放当前共享对象的锁,如果不释放会造成死锁),也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会立即获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行。

类似 wait 系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的 notify()方法,否则会抛出 IllegaMonitorStateException 异常。

Thread 类的方法:sleep(),yield()
Object 的方法:wait()和 notify()、notifyAll()

20.说说对象的监视器锁?

一个线程如何才能获取到对象的监视器锁呢

  • 执行 synchronized 同步代码块时,使用该共享变量作为参数。
synchronized (MonitorTest.class){
//do something
}
  • 调用该共享变量的方法,并且该方法使用了 synchronized 修饰。
synchronized void add(int a){
//do something
}

9.线程阻塞和唤醒方法对比?

wait()、notify()、notifyAll() 这三个方法是 Object 超类中的方法.

await()、signal()、signalAll() 这三个方法是 Lock 的 Condition 中的方法.

21.程序计数器为何线程私有?

为何要将程序计数器设置为线程私有的

程序计数器是一块内存区域,用来记录线程当前要执行的指令地址.线程是占用 CPU 执行的基本单位,而 CPU 一般是使用时间片轮转方式让线程轮询占用的,所以当前线程 CPU 时间片用完后,要让出 CPU,等下次轮到自己的时候再执行.那么如何知道之前程序执行到哪里了呢?其实程序计数器就是为了记录该线程让出 CPU 时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行.另外需要注意的是,如果执行的是 native 方法,那么 pc 计数器记录的是 undefined 地址,只有执行的是 Java 代码时 pc 计数器记录的才是下一条指令的地址。

22.jvm 内存划分?

局部变量,对象实例,jvm 加载的类,常量及静态变量都存储在内存的什么部位?是线程私有的吗

  • 每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧。
  • 堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用 new 操作创建的对象实例。
  • 方法区则用来存放虚拟机加载的类、常量及静态变量等信息,也是线程共享的。
23.继承 Thread 类的优劣?

使用继承方式的好处是,在 run()方法内获取当前线程直接使用 this 就可以了,无须使用 Thread.currentThread()方法;不好的地方是 Java 不支持多继承,如果继承了 Thread 类,那么就不能再继承其他类。另外任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码, Runable 则没有这个限制。

public class DemoTest extends Thread {
  // private int tickets = 20;
  private volatile int tickets = 20;

  @Override
  public void run(){
    synchronized (this){
      while (tickets > 0){
        System.out.println(Thread.currentThread().getName()+"卖出一张票"+ tickets);
        tickets--;
      }
    }
  }

  public static void main(String[] args){
    //实际上一共卖出了80张票,每个线程都有自己的私有的非共享数据。都认为自己有20张票
    DemoTest test4 = new DemoTest();
    DemoTest test5 = new DemoTest();
    DemoTest test6 = new DemoTest();
    DemoTest test7 = new DemoTest();
    test4.setName("一号窗口:");
    test5.setName("二号窗口:");
    test6.setName("三号窗口:");
    test7.setName("四号窗口:");
    test4.start();
    test5.start();
    test6.start();
    test7.start();
  }
}
24.直接调用 wait 方法?

IllegalMonitorStateException 出现的原因

如果调用 wait()方法的线程没有先获取该对象的监视器锁,则调用 wait 方法时调用线程会抛出 IllegalMonitorState Exception 异常。Object.notify(), Object.notifyAll(), Object.wait(), Object.wait(long), Object.wait(long, int)都会存在这个问题

public class ExceptionTest {

  public static void main(String[] args){
    Object object = new Object();
    try {
      object.wait();
    } catch (InterruptedException e){
      e.printStackTrace();
    }
  }
}
25.什么是虚假唤醒?

什么是虚假唤醒?如何避免虚假唤醒?

在一个线程没有被其他线程调用 notify()、 notifyAll()方法进行通知,或者被中断,或者等待超时,这个线程仍然可以从挂起状态变为可以运行状态(也就是被唤醒),这就是所谓的虚假唤醒。

虽然虚假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个 while 循环中调用 wait()方法进行防范.退出循环的条件是满足了唤醒该线程的条件。

synchronized (obj){
  //do something
  while (条件不满足){
    obj.wait();
  }
}

While 在这里是防止虚假唤醒的关键,试想下,一旦发生虚假唤醒,线程会根据 while 添加再次进行判断,一旦条件不满足,会立即再次将线程挂起.

26.说说 ThreadLocal?

ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本.当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。

ThreadLocalMap的Entry中的key使用的是对ThreadLocal对象的弱引用,这在避免内存泄漏方面是一个进步,因为如果是强引用,即使其他地方没有对ThreadLocal对象的引用,ThreadLocalMap中的ThreadLocal对象还是不会被回收,而如果是弱引用则ThreadLocal引用是会被回收掉的。

但是对应的value还是不能被回收,这时候ThreadLocalMap里面就会存在key为null但是value不为null的entry项,虽然ThreadLocalMap提供了set、get和remove方法,可以在一些时机下对这些Entry项进行清理,但是这是不及时的,也不是每次都会执行,所以在一些情况下还是会发生内存漏,因此在使用完毕后及时调用remove方法才是解决内存泄漏问题的王道。

创建一个 ThreadLocal 变量后,每个线程都会复制一个变量到自己的本地内存,如下图所示。

ThreadLocal

27.ThreadLocal 的原理?

Thread 类中有一个 threadLocals 和一个 inheritableThreadLocals,它们都是 ThreadLocalMap 类型的变量,而 ThreadLocalMap 是一个定制化的 Hashmap.在默认情况下,每个线程中的这两个变量都为 null,只有当前线程第一次调用 ThreadLocal 的 set 或者 get 方法时才会创建它们.其实每个线程的本地变量不是存放在 ThreadLocal 实例里面,而是存放在调用线程的 threadLocals 变量里面.也就是说,ThreadLocal 类型的本地变量存放在具体的线程内存空间中。ThreadLocal 就是一个工具壳,它通过 set 方法把 value 值放入调用线程的 threadLocals 里面并存放起来,当调用线程调用它的 get 方法时,再从当前线程的 threadLocals 变量里面将其拿出来使用.如果调用线程一直不终止,那么这个本地变量会一直存放在调用线程的 threadLocals 变量里面,所以当不需要使用本地变量时可以通过调用 ThreadLocal 变量的 remove 方法,从当前线程的 threadLocals 里面删除该本地变量。另外,Thread 里面的 threadLocals 为何被设计为 map 结构?很明显是因为每个线程可以关联多个 ThreadLocal 变量。

在 Thread 类中有以下变量

/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class.*/
 ThreadLocal.ThreadLocalMap threadLocals = null;

/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
* Inheritable 可继承的
*/
 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
  • ThreadLocal 中在 set 操作时,key 为当前 ThreadLocal 对象。
  • ThreadLocal 会为每个线程都创建一个 ThreadLocalMap,对应程序中的 t.threadLocals = new ThreadLocalMap(this, firstValue),ThreadLocalMap 为当前线程的属性。
  • 通过对每个线程创建一个 ThreadLocalMap 实现本地副本。当取值时,实际上就是通过 key 在 map 中取值,当然此时的 key 为 ThreadLocal 对象,而 map 为每个线程独有的 map,从而实现变量的互不干扰。
28.ThreadLocal 中 set 方法?
public class ThreadLocalTest {
  public static void main(String[] args){
    ThreadLocal<String> t1 = new ThreadLocal<>();
    t1.set("1");
    t1.set("2");
    System.out.println(t1.get());
  }
}
//输出结果 2

看下 set 方法的源代码:先获取当前线程 t,然后以 t 为 key 获取当前 ThreadLocalMap.如果 Map 存在则设置,注意设置的 key 为 this,this 代表当前对象,key 不变,所以 value 会被覆盖.如果 map 不存在则进行 createMap。

public void set(T value){
  Thread t = Thread.currentThread();
  ThreadLocalMap map = getMap(t);
  if (map != null){
    map.set(this, value);
  } else {
    createMap(t, value);
  }
}

void createMap(Thread t, T firstValue){
  t.threadLocals = new ThreadLocalMap(this, firstValue);
}
29.ThreadLocal 继承?
public class ThreadLocalTest2 {
  public static void main(String[] args){
    ThreadLocal<String> t1 = new ThreadLocal<>();
    t1.set("1");
    new Thread(new Runnable(){
      @Override
      public void run(){
        System.out.println("我是子线程,t1:"+ t1.get());
      }
    }).start();
    System.out.println("我是主线程,t1:"+ t1.get());
  }
}
//我是主线程,t1:1
//我是子线程,t1:null

也就是说,同一个 ThreadLocal 变量在父线程中被设置值后,在子线程中是获取不到的。根据之前题目的线程私有的介绍,这应该是正常现象,因为在子线程 thread 里面调用 get 方法时当前线程为 thread 线程,而这里调用 set 方法设置线程变量的是 main 线程,两者是不同的线程,自然子线程访问时返回 null.

30.子线程访问主线程变量?

有没有办法让子线程访问到主线程的 ThreadLocal 变量

InheritableThreadLocal 继承自 ThreadLocal,其提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量.下面看一下 InheritableThreadLocal 的代码。

public class InheritableThreadLocal<T> extends ThreadLocal<T> {
  protected T childValue(T parentValue){
    return parentValue;
  }
  ThreadLocalMap getMap(Thread t){
    return t.inheritableThreadLocals;
  }
  void createMap(Thread t, T firstValue){
    t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
  }
}

这是 InheritableThreadLocal 的全部代码,他继承了 ThreadLocal,并复写了三个方法.

  • 一个是 getMap,获取一个新的 map,
  • 一个是 ceateMap,创建一个新的 ThreadLocalMap,并赋值给 inheritableThreadLocals
  • 一个是 childValue,返回父线程的值
31.InheritableThreadLocal?

InheritableThreadLocal 是如何让子线程可以访问在父线程中设置的本地变量的

public class InheritableThreadLocal<T> extends ThreadLocal<T> {
  protected T childValue(T parentValue){
    return parentValue;
  }
  ThreadLocalMap getMap(Thread t){
    return t.inheritableThreadLocals;
  }
  void createMap(Thread t, T firstValue){
    t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
  }
}

从代码上看,getMap 和 createMap 没什么稀奇的,无非是创建和获取.这不是原理所在。

除了 getMap 和 createMap,只能来看看 childValue 这个方法了.我们看到代码逻辑是 return parentValue;

为了说清楚 childValue 这个方法,我们得先看 ThreadLocalMap 构造方法:从代码可以看出,初始化的时候进行了判断,如果父类的 inheritableThreadLocals 不为空,则进行 createInheriteMap 方法创建,继续点进去看

private ThreadLocalMap(ThreadLocalMap parentMap){
  Entry[] parentTable = parentMap.table;
  int len = parentTable.length;
  setThreshold(len);
  table = new Entry[len];

  for (int j = 0; j < len; j++){
    Entry e = parentTable[j];
    if (e != null){
      @SuppressWarnings("unchecked")
      ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
      if (key != null){
        //重点此处调用了 childValue,返回 parent的value,
        //在该函数内部把父线程 inhritblThreadLocal 的值复制到新的 ThreadLocalMap 对象
        Object value = key.childValue(e.value);
        Entry c = new Entry(key, value);
        int h = key.threadLocalHashCode &(len -1);
        while (table[h]!= null)
          h = nextIndex(h, len);
        table[h]= c;
        size++;
      }
    }
  }
}

总结:InheritableThreadLocal 类通过重写代码.getMap 和 createMap 让本地变量保存到了具体线程的 inheritableThreadLocals 变量里面,那么线程在通过 InheritableThreadLocal 类实例的 set 或者 get 方法设置变量时,就会创建当前线程的 inheritableThreadLocals 变量.当父线程创建子线程时,构造函数会把父线程中 inheritableThreadLocals 变量里面的本地变量复制一份保存到子线程的 inheritableThreadLocals 变量里面。

public class InheritableThreadLocalTest {

  public static void main(String[] args){
    InheritableThreadLocal<String> t1 = new InheritableThreadLocal<>();
    t1.set("1");
    new Thread(new Runnable(){
      @Override
      public void run(){
        System.out.println("我是子线程,t1:"+ t1.get());
      }
    }).start();
    System.out.println("我是主线程,t1:"+ t1.get());
  }
}
//我是主线程,t1:1
//我是子线程,t1:1

说说 InheritableThreadLocal 的使用场景?

情况还是蛮多的,比如子线程需要使用存放在 ThreadLocal 变量中的用户登录信息,再比如一些中间件需要把统一的 id 追踪的整个调用链路记录下来.其实子线程使用父线程中的 ThreadLocal 方法有多种方式,比如创建线程时传入父线程中的变量,并将其复制到子线程中,或者在父线程中构造一个 map 作为参数传递给子线程,但是这些都改变了我们的使用习惯,所以在这些情况下 InheritableThreadLocal 就显得比较有用。

32.?this 和 Thread.currentThread()区别?
  • Thread.currentThread 表示当前代码段正在被哪个线程调用的相关信息。
  • this 表示的是当前对象,与 Thread.currentThread 有很大的区别
33.CPU 用户态和内核态?

CPU 的两种工作状态:内核态(管态)和用户态(目态)。

内核态

  • 系统中既有操作系统的程序,也有普通用户程序.为了安全性和稳定性,操作系统的程序不能随便访问,这就是内核态.即需要执行操作系统的程序就必须转换到内核态才能执行!

  • 内核态可以使用计算机所有的硬件资源!

用户态

不能直接使用系统资源,也不能改变 CPU 的工作状态,并且只能访问这个用户程序自己的存储空间!当一个进程在执行用户自己的代码时处于用户运行态(用户态),此时特权级最低,为 3 级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态.Ring3 状态不能访问 Ring0 的地址空间,包括代码和数据;当一个进程因为系统调用陷入内核代码中执行时处于内核运行态(内核态),此时特权级最高,为 0 级.执行的内核代码会使用当前进程的内核栈,每个进程都有自己的内核栈.用户运行一个程序,该程序创建的进程开始时运行自己的代码,处于用户态.如果要执行文件操作、网络数据发送等操作必须通过 write、send 等系统调用,这些系统调用会调用内核的代码.进程会切换到 Ring0,然后进入内核地址空间去执行内核代码来完成相应的操作.内核态的进程执行完后又会切换到 Ring3,回到用户态.这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用.这说的保护模式是指通过内存页表操作等机制,保证进程间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程地址空间中的数据。

34.用户态/内核态切换条件?

当在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成一些用户态自己没有特权和能力完成的操作时就会切换到内核态。

用户态切换到内核态的 3 种方式

(1)系统调用

这是用户态进程主动要求切换到内核态的一种方式.用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作.例如 fork()就是执行了一个创建新进程的系统调用.系统调用的机制是使用了操作系统为用户特别开放的一个中断来实现.

(2)异常

当 cpu 在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。

(3)外围设备的中断

当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令是用户态下的程序,那么转换的过程自然就会是由用户态到内核态的切换.如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。

这三种方式是系统在运行时由用户态切换到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的.从触发方式上看,切换方式都不一样,但从最终实际完成由用户态到内核态的切换操作来看,步骤又是一样的,都相当于执行了一个中断响应的过程.系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本一致。

35.用户态切换内核态步骤?
  1. 从当前进程的描述符中提取其内核栈的 ss0 及 esp0 信息。
  2. 使用 ss0 和 esp0 指向的内核栈将当前进程的 cs,eip,eflags,ss,esp 信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。
  3. 将先前由中断向量检索得到的中断处理程序的 cs,eip 信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。
36.伪共享内存顺序冲突?

什么是伪共享内存顺序冲突?如何避免?

由于存放到 CPU 缓存行的是内存块而不是单个变量,所以可能会把多个变量存放到同一个缓存行中,当多个线程同时修改这个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,此时有两个线程同时修改同一个缓存行下的两个不同的变量,这就是伪共享,也称内存顺序冲突。当出现伪共享时,CPU 必须清空流水线,会造成 CPU 比较大的开销。

如何避免:JDK1.8 之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中,例如如下代码:

public final static class FilledLong{
  public volatile long value=0L;
  public long pl,p2,p3,p4,p5,p6;
}

假如缓存行为 64 字节,那么我们在 FilledLong 类里填充了 6 个 long 类型的变量,一个 long 类型变量占用 8 字节,加上自己的 value 变量占用的 8 个字节,总共 56 字节.另外,这里 FilledLong 是一个类对象,而类对象的字节码的对象头占用 8 字节,所以一个 FilledLong 对象实际会占用 64 字节的内存,这正好可以放入同一个缓存行。

JDK 提供了 sun.misc Contended 注解,用来解决伪共享问题.将上面代码修改为如下。

@sun.misc.Contended
  public final static class FilledLong{
    public volatile longvalue=0L;
  }

特别注意
在默认情况下,@Contended 注解只用于 Java 核心类,比如 rt 包下的类。
如果用户类路径下的类需要使用这个注解,需要添加 JVM 参数:- XX:-RestrictContended.填充的宽度默认为 128,要自定义填充宽度则可以通过参数-XX:ContendedPaddingWidth 参数进行设置。

37.对象的内存布局?

HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

  • 对象头:比如 hash 码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。Java 对象头一般占有 2 个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit 在 64 位虚拟机中,1 个机器码是 8 个字节,也就是 64bit),但是 如果对象是数组类型,则需要 3 个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
38.缓存一致性协议?

CPU 多级缓存,切换后如何保证一致性?

一种处理一致性问题的办法是使用 Bus Locking(总线锁)。当一个 CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信号。这个时候,所有 CPU 收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的 CPU 就去内存中获取最新数据更新。但是用锁的方式总是避不开性能问题。总线锁总是会导致 CPU 的性能下降。所以出现另外一种维护 CPU 缓存一致性的方式,MESI。

MESI 是保持一致性的协议。它的方法是在 CPU 缓存中保存一个标记位,这个标记位有四种状态:

  • M: Modify,修改缓存,当前 CPU 的缓存已经被修改了,即与内存中数据已经不一致了
  • E: Exclusive,独占缓存,当前 CPU 的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据
  • S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段
  • I: Invalid,实效缓存,这个说明 CPU 中的缓存已经不能使用了

CPU 的读取遵循下面几点:

  • 如果缓存状态是 I,那么就从内存中读取,否则就从缓存中直接读取。
  • 如果缓存处于 M 或 E 的 CPU 读取到其他 CPU 有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为 S。
  • 只有缓存状态是 M 或 E 的时候,CPU 才可以修改缓存中的数据,修改后,缓存状态变为 M。
39.线程优先级?

优先级是针对 CPU 的时间片的长短,不是先后.

prio=5 和 os_prio=0
默认优先级为 5,os_prio 是 jvm 相对于 os 的优先级,优先级的数值在 1~10,越大优先级越高
setPriority 这个方法,他是 jvm 提供的一个方法,并且能够调用本地方法 setPriority().我们发现优先级貌似没有起作用,为什么?

1.我们现在的计算机都是多核的,t1,t2 会让哪个 cpu 处理不好说。由不同的 cpu 同时提供资源执行。

2.优先级不代表先后顺序。哪怕你的优先级低,也是有可能先拿到我们的 cpu 时间片的,只不过这个时间片比高优先级的线程的时间片短。优先级针对的是 cpu 时间片的长短问题。

3.目前工作中,实际项目里,不必要使用 setPriority 方法。我们现在都是用 hystrix,sential 也好,一些开源的信号量控制工具,都能够实现线程资源的合理调度。这个 setPriority 方法,很难控制。实际的运行环境太复杂。

40.守护线程
  • 支持型线程
  • finally 里面的代码不一定会执行
public final void setDaemon(boolean on) {
  checkAccess();
  if (isAlive()) {//告诉我们,必须要先设置线程是否为守护线程,然后再调用start方法。如果你先调用start,后设置守护线程,则会抛出异常
    throw new IllegalThreadStateException();
  }
  daemon = on;
}

二.底层原理

1.说说重排序的分类?

在执行程序时,为了提高性能,编译器(jvm 里的)和处理器(操作系统级别的)常常会对指令做重排序.重排序分 3 种类型。

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行.如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序.这些重排序可能会导致多线程程序出现内存可见性问题.对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止).

对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers, Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

在单线程程序中,对存在数据依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果,因此必须要通过一定的同步手段加以控制。

2.说说重排序?

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

jmm 在实现上在不改变结果的前提下,编译器和处理器可以进行优化性的重排序.

image-20220425181243831

3.volatile 实现原则

volatile 实现原则有 2 条:

  • lock 前缀指令会使处理器缓存写回内存.
  • 一个处理器的缓存写回内存,会导致其他处理器的缓存失效.
4.volatile 的实现原理

为了实现 volatile 内存语义,JMM 会分别禁止如下两种类型的重排序类型:

image-20220425183219223

从图中可看出:

  1. volatile 写写禁止重排序

  2. volatile 读写,读读禁止重排序; volatile 读和普通写禁止重排序

  3. volatile 写读,volatile 写写禁止重排序。

image-20220425183259393

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序.对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能.为此,JMM 采取保守策略.下面是基于保守策略的 JMM 内存屏障插入策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

image-20220425183322017

public class Juc_book_fang_03_VolatileBarrierExample {
 int a;
 volatile int v1 = 1;
 volatile int v2 = 2;

 void readAndWrite(){
 int i = v1; //第一个volatile读
 int j = v2; //第二个volatile读
 a = i + j; //普通写
 v1 = i +1; //第一个volatile写
 v2 = j *2; //第二个 volatile写
//其他方法
}
}

屏障说明:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W5bx43zb-1660370613955)(https://s2.loli.net/2022/04/25/oqQwF4R2eWAjlv7.png)]

5.说说 volatile 的内存语义?

从内存语义的角度来说,volatile 的写一读与锁的释放-获取有相同的内存效果:

volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。

volatile 写的内存语义如下。

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

volatile 读的内存语义如下。

当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

image-20220425183129532

6.volatile 的特性?

简而言之,volatile 变量自身具有下列特性。

可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。JMM 模型,主存的本地内存数据一致性.添加了 Lock 指令.

原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++这种复合操作不具有原子性。

由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用 volatile 代替锁,请一定谨慎.

7.volatile 是如何保证可见性的?

如: volatile instance = new instance(); 示例代码中, instance 被 volatile 修饰。
上边的 new 操作,转化成汇编代码如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8SseSMqs-1660370613960)(https://s2.loli.net/2022/06/11/CStpM3EqlLR5zIF.png)]
有 volatile 变量修饰的共享变量进行写操作的时候会多出第二行 Lock 汇编代码, Lock 前缀的指令在多核处理器下会引发了两件事情: 1)将当前处理器缓存行的数据写回到系统内存.(volatile 写的内存语义) 2)这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效.(volatile 读的内存语义)
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里(volatile 读的内存语义)。

8.Synchonized 三种使用

java 中的每一个对象都可以作为锁。具体表现为以下 3 种形式。

  • 对于普通同步方法,锁是当前实例对象。ACC_SYNCHRONIZED
  • 对于静态同步方法,锁是当前类的 Class 对象。ACC_SYNCHRONIZED 和 ACC_STATIC
  • 对于同步方法块,锁是 Synchonized 括号里配置的对象。monitorenter 和 monitorexit,其中 monitorexit 至少有 2 个出口,一个正常出口,一个异常出口

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁

当出现读少写多时,volatile 并不合适,提高吞吐量还得靠重量级锁.

9.Synchonized 的实现原理

在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 MutexLock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,主要是锁升级的过程. owner 表示 Monitor 锁的持有者,而且同一个时刻只能有一个 owner.

JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样。

代码块同步是使用 monitorenter 和 monitorexit 指令实现的,而方法同步是使用另外一种方式实现的。

monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。

任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态.线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

修饰方法

public class SynchonizedTest1 {
  private static int a = 0;
  public  synchronized void add(){
    a++;
  }
}

可以看到在 add 方法的 flags 里面多了一个 ACC_SYNCHRONIZED 标志,这标志用来告诉 JVM 这是一个同步方法

┌─[qinyingjie@qinyingjiedeMacBook-Pro]-[~/Documents/idea-workspace/ant/ant-juc/target/classes/com/xiaofei/antjuc/方腾飞]-[Thu Apr 14,18:51]
└─[$]<git:(master*)> javap -v SynchonizedTest1.class
Classfile /Users/qinyingjie/Documents/idea-workspace/ant/ant-juc/target/classes/com/xiaofei/antjuc/方腾飞/SynchonizedTest1.class
 Last modified 2022-4-14; size 486 bytes
 MD5 checksum 1a0bdb0e66832a2980bb5b8c0a58eff7
 Compiled from "SynchonizedTest1.java"
public class com.xiaofei.antjuc.方腾飞.SynchonizedTest1
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18// java/lang/Object."<init>":()V
#2 = Fieldref #3.#19// com/xiaofei/antjuc/方腾飞/SynchonizedTest1.a:I
#3 = Class #20// com/xiaofei/antjuc/方腾飞/SynchonizedTest1
#4 = Class #21// java/lang/Object
#5 = Utf8  a
#6 = Utf8  I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8  Code
#10 = Utf8  LineNumberTable
#11 = Utf8  LocalVariableTable
#12 = Utf8  this
#13 = Utf8  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest1;
#14 = Utf8  add
#15 = Utf8 <clinit>
#16 = Utf8  SourceFile
#17 = Utf8  SynchonizedTest1.java
#18 = NameAndType #7:#8 //"<init>":()V
#19 = NameAndType #5:#6 // a:I
#20 = Utf8  com/xiaofei/antjuc/方腾飞/SynchonizedTest1
#21 = Utf8  java/lang/Object
{
 public com.xiaofei.antjuc.方腾飞.SynchonizedTest1();
 descriptor: ()V
 flags: ACC_PUBLIC
 Code:
 stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
 LineNumberTable:
 line 3: 0
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 5 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest1;

 public synchronized void add();
 descriptor: ()V
 flags: ACC_PUBLIC, ACC_SYNCHRONIZED
 Code:
 stack=2, locals=1, args_size=1
0: getstatic #2 // Field a:I
3: iconst_1
4: iadd
5: putstatic #2 // Field a:I
8: return
 LineNumberTable:
 line 6: 0
 line 7: 8
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 9 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest1;

 static {};
 descriptor: ()V
 flags: ACC_STATIC
 Code:
 stack=1, locals=0, args_size=0
0: iconst_0
1: putstatic #2 // Field a:I
4: return
 LineNumberTable:
 line 4: 0
}
SourceFile: "SynchonizedTest1.java"

修饰类

有两个 monitorexit 呢?

第一个:正常退出

第二个:异常退出

public class SynchonizedTest2 {
 private static int a = 0;

 public void add(){
 synchronized (SynchonizedTest2.class){
 a++;
}
}
}
方腾飞|master⚡⇒ jjavap -v SynchonizedTest2
警告: 二进制文件SynchonizedTest2包含com.xiaofei.antjuc.方腾飞.SynchonizedTest2
Classfile /Users/qinyingjie/Documents/idea-workspace/ant/ant-juc/target/classes/com/xiaofei/antjuc/方腾飞/SynchonizedTest2.class
 Last modified 2022-4-14; size 599 bytes
 MD5 checksum e4ad4e62082f26cefee3bb1715e94295
 Compiled from "SynchonizedTest2.java"
public class com.xiaofei.antjuc.方腾飞.SynchonizedTest2
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#22// java/lang/Object."<init>":()V
#2 = Class #23// com/xiaofei/antjuc/方腾飞/SynchonizedTest2
#3 = Fieldref #2.#24// com/xiaofei/antjuc/方腾飞/SynchonizedTest2.a:I
#4 = Class #25// java/lang/Object
#5 = Utf8  a
#6 = Utf8  I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8  Code
#10 = Utf8  LineNumberTable
#11 = Utf8  LocalVariableTable
#12 = Utf8  this
#13 = Utf8  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest2;
#14 = Utf8  add
#15 = Utf8  StackMapTable
#16 = Class #23// com/xiaofei/antjuc/方腾飞/SynchonizedTest2
#17 = Class #25// java/lang/Object
#18 = Class #26// java/lang/Throwable
#19 = Utf8 <clinit>
#20 = Utf8  SourceFile
#21 = Utf8  SynchonizedTest2.java
#22 = NameAndType #7:#8 //"<init>":()V
#23 = Utf8  com/xiaofei/antjuc/方腾飞/SynchonizedTest2
#24 = NameAndType #5:#6 // a:I
#25 = Utf8  java/lang/Object
#26 = Utf8  java/lang/Throwable
{
 public com.xiaofei.antjuc.方腾飞.SynchonizedTest2();
 descriptor: ()V
 flags: ACC_PUBLIC
 Code:
 stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
 LineNumberTable:
 line 3: 0
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 5 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest2;

 public void add();
 descriptor: ()V
 flags: ACC_PUBLIC
 Code:
 stack=2, locals=3, args_size=1
0: ldc #2 // class com/xiaofei/antjuc/方腾飞/SynchonizedTest2
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field a:I
8: iconst_1
9: iadd
10: putstatic #3 // Field a:I
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
 Exception table:
from  to  target type
5 15 18  any
18 21 18  any
 LineNumberTable:
 line 7: 0
 line 8: 5
 line 9: 13
 line 10: 23
 LocalVariableTable:
 Start  Length  Slot  Name  Signature
0 24 0  this  Lcom/xiaofei/antjuc/方腾飞/SynchonizedTest2;
 StackMapTable: number_of_entries = 2
 frame_type = 255 /* full_frame */
 offset_delta = 18
 locals = [ class com/xiaofei/antjuc/方腾飞/SynchonizedTest2, class java/lang/Object ]
 stack = [ class java/lang/Throwable ]
 frame_type = 250 /* chop */
 offset_delta = 4

 static {};
 descriptor: ()V
 flags: ACC_STATIC
 Code:
 stack=1, locals=0, args_size=0
0: iconst_0
1: putstatic #3 // Field a:I
4: return
 LineNumberTable:
 line 4: 0
}
SourceFile: "SynchonizedTest2.java"
10.Synchonized 锁信息在对象中的存储位置?

synchronized 用的锁是存在 Java 对象头(Mark Word)里的。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit.数组的 1 字宽存储数组的长度信息.

无锁状态下 32 位 JVM 的 Mark Word 的默认存储结构如下:

img

有锁状态的 Mark Word 的信息变化如下,并从下图中能够看到锁的信息的确是放到 Mark Word 中的,并且不同的锁类型, Mark Word 中的信息会有变化。

11.synchronized 中的同步队列与等待队列?

同步队列:排队取锁的线程所在的队列
等待队列:调用 wait 方法后,线程会从同步队列转移到等待队列
synchronized 中同步队列有两个_cxq 与 EntryList,基于不同的 QMode 来调整线程的出队策略
_cxq(竞争队列):抢锁失败后,线程会进入此队列,此队列大部分情况时单向链表,入队策略是后来者当头
EntryList:默认情况下,线程被唤醒时,会从等待队列转移到此队列,此队列是一个双向链表
WaitSet:等待队列,调用 wait 方法后,线程会进入此队列

12.ObjectMonitor 的属性
  • header : 重量级锁保存 markword 的地方
  • own: 指向我们持有锁的线程;对象的 markword 里边也保存了指向 monitor 的指针;
  • _cxq 队列: 竞争队列。 A 线程持有锁没有释放; B 和 C 线程同时过来争抢锁,都被 block 了,此时会将 B 和 C 线程加入到 该队列。
  • EntryList 队列:同步队列。A 线程释放锁,B 和 C 线程中会选定一个继承者(可以去争抢锁的这个线程),另外一个线程会被放入我们的 EntryList 队列里边。
  • waitset:等待队列。Object wait 的线程。
  • A 线程持有锁,BC 线程过来竞争失败,进入 cxq – 下轮竞争会把 cxq 里的线程移动到 EntrylIst 中。假设 B 线程竞争到了锁,然后 B 线程调用了 Object.Wait 方法,这时候 B 线程进入 waitset,并释放锁。C 线程拿到了锁,然后唤醒 B 线程。B 线程会从 waitset 里边出来,直接竞争锁。如果竞争失败进入 cxq,继续轮回,如果竞争成功,ok 了。
13.描述下锁分类和锁升级?

Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6 中,锁一共有 4 种状态,级别从低到高依次是:

锁的类型

  • non-biasable 无锁且不可偏向
  • biasable 无锁可偏向
  • biased 偏向锁
  • thin lock 轻量级锁
  • fat lock 重量级锁

锁的标志

  • 无锁状态 01 偏向标志位 0
  • 偏向锁状态 01 偏向标志位 1
  • 轻量级锁状态 00
  • 重量级锁状态 10
  • GC 11

这几个状态会随着竞争情况逐渐升级.锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁.这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

image-20220727001311576

14.偏向锁的原理?

为了在只有一个线程执行同步块时提高性能,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1 (表示当前是偏向锁):

  • 如果没有设置,则使用 CAS 竞争锁;
  • 如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。

当设置了 HashCode 后,不能设置偏向锁,只能升级为轻量级锁,因为 HashCode 生成后,没办法把 HashCode 占位改为偏向线程的 id,这里 HashCode 会存在显式调用(直接调用 HashCode 方法)和隐式调用(比如 HashMap 的 put 方法).

15.批量重偏向和批量撤销?

真正的锁升级,是依赖于 class 的,而并不是依赖于某一个 new 出来的对象(偏向锁升级为轻量级锁)。

真正的锁升级,是依赖于当前 new 出来的对象的(轻量级锁升级为重量级锁)

轻量级锁升级为重量级锁:这个时候,只要我们的线程发生了竞争,并且 CAS 替换失败,就会发起锁膨胀,升级为重量级锁(针对的是一个对象实例)。

rebias & revoke
bulk rebias(批量重偏向):如果已经偏向 t1 线程的对象,在 t2 线程申请锁时撤销偏向后升级为轻量级锁的对象数量达到一定值(20),后续的申请会批量重偏向到 t2 线程;
bulk revoke(批量撤销):在单位时间(25s)内某种 Class 的对象撤销偏向的次数达到一定值(40),JVM 认定该 Class 竞争激烈,撤销所有关联对象的偏向锁,且新实例也是不可偏向的;并且有第三条线程 C 加入了,这个时候会触发批量撤销。JVM 会标记该对象不能使用偏向锁,以后新创建的对象,直接以轻量级锁开始。这个时候,才是真正的完成了锁升级。

-XX:+UseBiasedLocking 启用偏向锁,默认启用
-XX:+PrintFlagsFinal 打印JVM所有参数
-XX:BiasedLockingStartupDelay=4000 偏向锁启用延迟时间,默认4秒
-XX:BiasedLockingBulkRebiasThreshold=20 批量重偏向阈值,默认20
-XX:BiasedLockingBulkRevokeThreshold=40 批量撤销阈值,默认40
-XX:BiasedLockingDecayTime=25000
16.偏向锁撤销原理?

偏向锁使用了一种等到竞争出现才释放偏向锁的机制:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WgveuPnY-1660370613961)(https://s2.loli.net/2022/04/15/l5Hoa2tPcXEDOZ8.png)]

17.JVM 偏向锁可以撤销吗?

偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动 4 秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

18.轻量级锁加锁和解锁的过程?

轻量级锁是为了在线程近乎交替执行同步块时提高性能。

轻量级锁加锁

  • 线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录空间中,官方称为 Displaced Mark Word;
  • 然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。
  • 如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级解锁

  • 会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生.如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁
19.Mark Word 说明

Mark Word 64 位,格式如下:

img

java 对象头长度

具体分析:

如果是无锁状态,则分布为 25 位 unused,31 位 hashcode,1 位 unused,4 位 age,偏向标志位,锁的标志位

如果是偏向锁状态,54 位 thread 的 id,2 位 epoch,1 位 unused,4 位 age,偏向标志位,锁的标志位

如果是轻量级锁,62 位的 lock_record,锁的标志位

如果是重量级锁,62 位的 monitor 指针,锁的标志位,owner 指向持有锁的线程

synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽(W/ord)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit,如表 2-2 所示。

image-20220422154243000

20.描述下偏向锁到轻量级锁再到重量级锁的过程?

偏向锁:
1.A 线程获取偏向锁,并且 A 线程死亡退出。B 线程争抢偏向锁,会直接升级当前对象的锁为轻量级锁。这只是针对我们争抢了一次。
2.A 线程获取偏向锁,并且 A 线程没有释放偏向锁,还在 sync 的代码块里边。B 线程此时过来争抢偏向锁,会直接升级为重量级锁。
3.A 线程获取偏向锁,并且 A 线程释放了锁,但是 A 线程并没有死亡还在活跃状态。B 线程过来争抢,会直接升级为轻量级锁。综上所述,当我们尝试第一次竞争偏向锁时,如果 A 线程已经死亡,升级为轻量级锁;如果 A 线程未死亡,并且未释放锁,直接升级为重量级锁;如果 A 线程未死亡,并且已经释放了锁,直接升级为轻量级锁。
4.A 线程获取偏向锁,并且 A 线程没有释放偏向锁,还在 sync 的代码块里边。B 线程多次争抢锁,会在加锁过程中采用重量级锁;但是,一旦锁被释放,当前对象还是会以轻量级锁的初始状态执行。
5.A 线程获取偏向锁,并且 A 线程释放了锁,但是 A 线程并没有死亡还在活跃状态。B 线程过来争抢。部分争抢会升级为轻量级锁;部分争抢会依旧保持偏向锁。

偏向锁到轻量级锁:

线程 1 作为持有者,线程 2 作为竞争者出现了,线程 2 由于 cas 替换偏向锁中的线程 id 失败,发起了撤销偏向锁的动作.此时线程 1 还存活,暂停了线程 1 的线程,此时线程 1 的栈中的锁记录会被执行遍历,将对象头中的锁的是否是偏向锁位置改成 0,并将锁标志位从 01(偏向锁)改成 00(轻量级锁),升级为轻量级锁。

image-20220415115227383

轻量级锁到重量级锁:

线程 1 为锁的持有者,线程 2 为竞争者.线程 2 尝试 CAS 操作将轻量级锁的指针指向自己栈中的锁记录失败后。发起了升级锁的动作。线程 2 会将 Mark Word 中的锁指针升级为重量级锁指针。自己处于阻塞状态,因为此时线程 1 还没释放锁。当线程 1 执行完同步体后,尝试 CAS 操作将 Displaced Mark Word 替换回到对象头时,此时肯定会失败,因为 mark word 中已经不是原来的轻量级指针了,而是线程 2 的重量级指针.那么此时线程 1 很无奈,只能释放锁,并唤醒其他线程进行锁竞争。此时线程 2 被唤醒了,获取了重量级锁。

轻量级锁---重量级锁:
释放锁(前四步)并唤醒等待线程
1.线程1初始化monitor对象;
2.将状态设置为膨胀中(inflating);
3.将monitor里边的header属性,set称为对象的markword;(将自己lockrecord里边的存放的markword的hashcode,分代年龄,是否为偏向锁set到objectmonitor对象的header属性里)
4.设置对象头为重量级锁状态(标记为改为00);然后将前30位指向monitor对象;(真正的锁升级是由线程1操控的)
5.唤醒线程2;
6.线程2开始争抢重量级锁。

image-20220414191253418

21.比较三种锁的优缺点及使用场景?

image-20220414191542272

其实偏向锁,本就为一个线程的同步访问的场景.在出现线程竞争非常小的环境下,适合偏向锁。轻量级锁自旋获取线程,如果同步块执行很快,能减少线程自旋时间,采用轻量级锁很适合。重量级锁就不用多说了,synchronized 就是经典的重量级锁。使用 synchronized 不一定会升级为重量级锁,得看条件.

偏向锁:

自始至终,对这把锁都不存在竞争,只需要做个标记,这就是偏向锁,每个对象都是一个内置锁(内置锁是可重入锁),一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有线程来访问并尝试获取锁的时候,它就会把这个线程记录下来,以后如果获取锁的线程正式偏向锁的拥有者,就可以直接获得锁,偏向锁性能最好。

轻量级锁:

轻量级锁是指原来是偏向锁的时候,这时被另外一个线程访问,存在锁竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。

重量级锁:

重量级锁是互斥锁,主要是利用操作系统的同步机制实现的,当多个线程直接有并发访问的时候,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就升级为重量级锁,重量级锁会使得其他拿不到锁的线程陷入阻塞状态,重量级锁的开销相对较大。

偏向所锁,轻量级锁都是乐观锁,重量级锁是悲观锁。

  • 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
  • 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
  • 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
22.为什么要引入轻量级锁?

解答这个问题,先要自问一句,不引入轻量级锁,直接用重量级锁有什么坏处。我们知道重量级锁,如果线程竞争锁失败,会直接进入阻塞(Blocked)状态,阻塞线程需要 CPU 从用户态转到内核态,代价较大,假设一个线程刚刚阻塞不久这个锁就被释放了,这个线程被唤醒后,还需要从内核态切换到用户态,一来一回就两次状态切换,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋的等待锁释放。如果自旋的等待锁的释放,正好是我们的轻量级锁的特性,那么为什么引入轻量级锁就明白了。

23.什么是适应性自旋?和普通自旋的区别?

JDK 1.5 的自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。自旋次数可以设定,通过-XX:PreBlockSpin=10 自行设置自旋次数,此处举例说明设置为 10 次。

在 JDK 1.6 引入了适应性自旋锁,XX:PreBlockSpin 参数也就没有用了.适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时 JVM 还针对当前 CPU 的负荷情况做了较多的优化,如下三点优化非常突出。

  1. 如果平均负载小于 CPU 则一直自旋

  2. 如果有超过(CPU/2)个线程正在自旋,则后来线程直接阻塞(升级为重量级锁)

  3. 如果正在自旋的线程发现 Owner 发生了变化则延迟自旋时间(自旋计数)或进入阻塞(升级为重量级锁)

24.JVM 如何开启轻量级锁?

JDK 1.5 使用- XX:+UseSpinning 手动开启。

JDK1.6 及后续版本默认开启轻量级锁。

25.对于同步方法,处理器如何实现原子操作?

处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

总线锁定:

如果多个处理器同时对非同步共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致.原因可能是多个处理器同时从各自的缓存中读取变量 i,分别进行加 1 操作,然后分别写入系统内存中。

对于同步方法操作 i++时,部分处理器使用总线锁就是来解决这个问题的.所谓总线锁就是使用处理器提供的一个 LOCK#信号(参见 93 题的 Lock 汇编指令),当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存,只不过总线锁定开销很大。

缓存锁定:

所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在 Lock 操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言 LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效.

26.缓存锁定性能优于总线锁定,为什么不淘汰总线锁定?

有两种情况下处理器不会使用缓存锁定。

第一种情况是:当操作的数据不能被缓存在处理器内部(比如外部磁盘数据),或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。

第二种情况是:有些处理器不支持缓存锁定.对于 Intel 486 和 Pentium 处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

27.什么是原子操作?说说 i++操作

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。

i++是读改写系列操作,操作中包括如下三个:

  • 读操作:读 i 的当前值;
  • 改操作:在 i 的当前值上做+1 操作;
  • 写:将修改后的值写回内存。
28.java 如何保证原子操作的?

在 Java 中可以通过锁和循环 CAS 的方式来实现原子操作

CAS:从 Java 1.5 开始,JDK 的并发包里提供了一些类来支持原子操作,如 AtomicBoolean (用原子方式更新 boolean 值)、 AtomicInteger (用原子方式更新 int 值)和 AtomicLong (用原子方式更新的 long 值),其中就是依靠 CAS 操作来完成的。

锁:如 synchronized 以及 Lock 锁,线程获取对象锁之后,会完成系列操作后释放锁,运行期间,其他线程会处于阻塞状态,因此是原子性的操作.

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域.JVM 内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁.有意思的是除了偏向锁,JVM 实现锁的方式都用了循环 CAS,即当一个线程想进入同步块的时候使用循环 CAS 的方式来获取锁,当它退出同步块的时候使用循环 CAS 释放锁

29.锁的获取和释放内存语义?

对比锁释放-获取的内存语义与 volatile 写一读的内存语义可以看出:

锁释放与 volatile 写有相同的内存语义;

锁获取与 volatile 读有相同的内存语义。

下面对锁释放和锁获取的内存语义做个总结。

  • 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了(线程 A 对共享变量所做修改的)消息。

  • 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变变量所做修改的)消息。

  • 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

image-20220425184008576

从对 ReentrantLock 的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式。

1)利用 volatile 变量的写-读所具有的内存语义。

2)利用 CAS 所附带的 volatile 读和 volatile 写的内存语义。

30.CAS 操作的原理?

JDK 文档对该方法的说明如下:如果当前状态值(内存值)等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义

所谓的 CAS,其实是个简称,全称是 Compare And Swap,对比之后交换数据.内存值–预期值–新值

//原子类Atomic中的cas
public final boolean compareAndSet(boolean expect, boolean update){
 int e = expect ? 1 : 0;
 int u = update ? 1 : 0;
 return unsafe.compareAndSwapInt(this, valueOffset, e, u);
}
//底层实现是用的Unsafe的cas,包含3个方法
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

上面的方法,有几个重要的参数:

(1)this: Unsafe 对象本身,需要通过这个类来获取 value 的内存偏移地址。

(2)valueOffset:value 变量的内存偏移地址。

(3)expect:期望更新的值。

(4)update:要更新的最新值。

其中步骤(1)和步骤(2)是根据内存偏移地址获取当前的值 value,expect 值是未修改之前的 value 值.如果修改时通过内存偏移地址获取到的 value 与 except 的 value 值一样,则进行更新,否则不更新,再次从新获取 value 值,循环下去,直到成功为止.

//CAS-c++源码:
inline jint Atomic::cmpxchg (jint exchange value, volatile jint*dest,
                             jint compare value){
  // alternative for InterlockedCompareExchange
  int mp=os::isMP();//是否为多核心处理器
  _asm {
    mov edx, dest //要修改的地址
      mov ecx, exchange_value //新值值
      mov eax,compare_value //期待值
      LOCK_IF_MP(mp) //如果是多处理器,在下面指令前加上LOCK前缀
      cmpxchg dword ptr [edx],ecx//[edx]与eax对比,相同则[edx]=ecx,否则不操作
  }
}

这里看到有一个 LOCK_IF_MP,作用是如果是多处理器,在指令前加上 LOCK 前缀,因为在单处理器中,是不会存在缓存不一致的问题的,所有线程都在一个 CPU 上跑,使用同一个缓存区,也就不存在本地内存与主内存不一致的问题,不会造成可见性问题.然而在多核处理器中,共享内存需要从写缓存中刷新到主内存中去,并遵循缓存一致性协议通知其他处理器更新缓存.Lock 在这里的作用:

  • 在 cmpxchg 执行期间,锁住内存地址[edx],其他处理器不能访问该内存,保证原子性.即使是在 32 位机器上修改 64 位的内存也可以保证原子性。
  • 将本处理器上写缓存全部强制写回主存中去,保证每个线程的本地内存与主存一致。
  • 禁止 cmpxchg 与前后任何指令重排序,防止指令重排序。
31.CAS 存在的问题,ABA 问题,如何解决的?

主要有 3 个问题

  • ABA 问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作

ABA 问题.因为 CAS 需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了.ABA 问题的解决思路就是使用版本号.在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A→ B→A 就会变成 1A→2B→3A。

解决一:从 Java 1.5 开始,JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题.这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

解决二:使用 AtomicMarkableReference 可以通过 Boolean 类型进行判断

32.CAS 循环时间太长,会有什么问题?

自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。

使用 CAS 自旋,需要考虑业务场景是否是多任务快速处理的场景,如果单个任务处理够快且任务量大,使用 CAS 会带来很好地效果.轻量级锁的设计原理底层就是使用了 CAS 的操作原理。

33.final 域的内存语义?

对于 final 域,编译器和处理器要遵守两个重排序规则。

  • 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含 fnal 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
  • 简单说,普通变量可能会重排序,final 变量在构造函数返回之前不会被其他引用提前访问
public class Juc_book_fang_12_FinalExample {
    int i; // 普通变量
    final int j; // final变量
    static Juc_book_fang_12_FinalExample obj;

    public Juc_book_fang_12_FinalExample() { // 构造函数
        i = 1; // 写普通域
        j = 2; // 写final域
    }

    public static void writer() { // 写线程A执行
        obj = new Juc_book_fang_12_FinalExample();
    }

    public static void reader() { // 读线程B执行
        Juc_book_fang_12_FinalExample object = obj;// 读对象引用
        int a = object.i; // 读普通域
        int b = object.j; // 读final域
    }
}

写 final 规则

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NhOPLR2L-1660370613967)(https://s2.loli.net/2022/06/09/FTj5Gh6nbB8DAVY.png)]

读 final 重排序规则

image-20220609143409269

34.内存屏障的种类以及说明?

image-20220425175438754

StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他 3 个屏障的效果.现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持).执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush).

35.说说对于 happens-before 的理解?

与程序员密切相关的 happens-before 规则如下。

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。

  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。

  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

  • 传递性:如果 Ahappens-beforeB,且 Bhappens-beforeC,那么 Ahappens-beforeC

注意两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the frst is visible to and ordered before the second)。happens-before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。

image-20220425180332637

36.什么是 as-if-serial 语义?

不管怎么重排序,单线程执行结果不变

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

37.双重检查锁的单例模式
public class Juc_book_fang_11_Dcl {
  private static volatile Person instance;
  public static Person getInstance(){
    if (instance == null){//步骤一
      synchronized (Juc_book_fang_11_Dcl.class){//步骤二
        if (instance == null){//步骤三
          instance = new Person();//步骤四
        }
        return instance;
      }
    }
    return instance;
  }
}

看着图中的注释,假设线程 A 执行 getInstance 方法

步骤一: instance 为 null,则进入 if 判断;

步骤二:获取 synchronized 锁,成功,进入同步代码块;

步骤三:继续判断 instance,为 null 则进入 if 判断;

步骤四: instance = new Instance().看似是一句代码,其实是三句代码。

memory=allocate(); //1:分配对象的内存空间
ctorInstance(memory);//2:初始化对象
instance=memory; //3:设置instance指向刚分配的内存地址

上面 2 和 3 两者没有依赖关系,设置 instance 指向刚分配的内存地址和初始化对象会存在重排序.

使用 volatile 并不会解决 2 和 3 的重排序问题,因为 2 和 3 都在一个 new 指令里面,内存屏障是针对指令级别的重排序,双重检查锁 volatile 禁止重排序的原理,new 指令是单一指令,也就是前面加 StoreStore 屏障,后面加 StoreLoad 屏障,后面的线程必不会读到 instance 为 null

有 2 种解决方案:

  • 使用 volatile 禁止重排序,原理还是其他线程不可见

  • 允许 2 和 3 重排序,但是不允许其他线程可见

    • 基于类初始化
    • CLASS 对象的初始化锁只能有一个线程访问,对其他线程不可见

基于类的初始化

public class InstanceFactory {
  private static class InstanceHolder {
    public static Instance instance=new Instance();
  }
  public static Instance getinstance() {
    return InstanceHolder.instance; // 这里将导致InstanceHolder类被初始化
  }
}

java 中,一个类或接口类型 T 将被立即初始化的情况如下

  1. T 是一个类,而且一个 T 类型的实例被创建。

  2. T 是一个类,且 T 中声明的一个静态方法被调用。

  3. T 中声明的一个静态字段被赋值。

  4. T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段。

  5. T 是一个顶级类(TopLevelClass,见 Java 语言规范的§7.6),而且一个断言语句嵌套在 T 内部被执行。

    在示例代码中,首次执行 getlnstance)方法的线程将导致 InstanceHolder 类被初始化(符合情况 4)。

38.fail-fast 和 fail-safe 对比?
对比项fail-fastfail-safe
Throw ConcurrentModification Exception不会
Clone不会
Memory Overhead不会
ExamplesHashMap Vector ArrayList HashSetCopyOnWriteArrayList

fail-safe 也是得具体情况具体分析的。

  1. 如果是 CopyOnWriteArrayList 或者 CopyOnWriteArraySet ,就属于 复制原来的集合,然后在复制出来的集合上进行操作 的情况 ,所以是不会抛出这个 ConcurrentModificationException 的 。
  2. 如果是这个 concurrentHashMap 的,就比较硬核了~ 😄 它直接操作底层,调用 UNSAFE.getObjectVolatile ,直接 强制从主存中获取属性值,也是不会抛出这个 ConcurrentModificationException 的 。
  3. 并发下,无法保证遍历时拿到的是最新的值
39.Mark Word 中的 epoch

epoch 是 Mark Word 中的一部分,只存在于偏向锁状态,占 2 位。锁对应的类同样用 2 位存了 epoch。

epoch 的作用就是标记偏向的合法性,说通俗点,就是看这个偏向锁有没有其他线程正在用。如果不用 epoch,那每次想要重偏向,都得去遍历所有的线程栈看看有没有其他线程在用。

每次撤销数量刚到 20 的时候,锁的类 epoch 都会+1,并且更新加锁状态的同类锁对象,那么那些不加锁的锁对象 epoch 就和类的 epoch 不一样了,那就可以知道哪些偏向锁是空闲的了。

epoch 只有两位,肯定会循环,但不会影响准确性。

三.常见的锁

1.谈谈常见的锁有哪些?

img

  1. 乐观锁/悲观锁
    • 悲观锁:坏事一定会发生,先上锁
    • 乐观锁:未必会发生,事后补偿
      • 自旋锁:一种常见的乐观锁的实现 CAS
      • ABA 问题
        • 添加版本号 AtomicStampedReference
        • 添加 Boolean 标示 AtomicMarkableReference
  2. 公平锁/非公平锁
    • 公平锁:FIFO,先到先得
    • 非公平锁:抢占式
    • ReentrantLock 可以是公平锁也可以是非公平锁
  3. 排他锁/共享锁
    • 排他锁:只有一个线程能访问代码
      • ReentrantLock
      • Worker
    • 共享锁:可以有多个线程同时访问代码
      • 信号量 Semaphore:漏斗+停车场入口
      • CountDownLatch
    • 共享+排他
      • ReentrantReadWriteLock
  4. 读写锁
    • 读锁:读的时候,不允许写,可以多个读
    • 写锁:写的时候,不允许读,不允许写
  5. 自旋锁
    • 是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
    • 自旋锁缺点
      • 非公平的,有些线程等待时间过长
      • 消耗 cpu
    • 自旋锁优点
      • 不发生线程状态的切换,始终是用户态,减少了上下文的切换
      • 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换
2.Synchronized 和 Lock 区别?
synchronizedLock
原始构成sync是 JVM 层面的,底层通过monitorentermonitorexit来实现的Lock是 JDK API 层面的
使用方法sync不需要手动释放锁Lock需要手动释放
是否可中断sync不可中断,除非抛出异常或者正常运行完成Lock是可中断的,通过调用interrupt()方法
是否为公平锁sync只能是非公平锁Lock既能是公平锁,又能是非公平锁
绑定多个条件sync不能,只能随机唤醒Lock可以通过Condition来绑定多个条件,精确唤醒

synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API

Lock 接口提供的 synchronized 关键字不具备的主要特性

特性描述
尝试非阻塞地获取锁当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁
能被中断地获取锁与 synchronized 不同,获取到锁的线程能能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
超时获取锁在指定的截止时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回

Lock 的 API

方法名称描述
void lock()获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回
void lockInterruptibly()) throws InterruptedException可中断地获取锁,和 lock0 方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程
boolean tryLock()尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回 true,否则返回 false
boolean tryLock(long time.TimeUnit unit)throws InterruptedException超时的获取锁,当前线程在以下 3 种情况下会返回:
① 当前线程在超时时间内获得了锁
② 当前线程在超时时间内被中断
③ 超时时间结束,返回 false
void unlock()释放锁
Condition newCondition()获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的 waitO 方法,而调用后,当前线程将释放锁
  1. synchronized 是一个关键词,lock 是一个接口
  2. synchronized 是隐式调用,lock 是显示调用
  3. synchronized 可以作用到方法,lock 只能作用到代码块
  4. Lock 支持非阻塞式加锁,
  5. Lock 支持超时加锁
  6. Lock 支持可中断加锁
  7. synchronized 采用的 monitor 监视器,lock 采用的是 AQS
  8. synchronized 只有 2 个同步队列,一个等待队列,lock 可以有多个等待队列,一个同步队列
  9. synchronized 只支持非公平锁,lock 支持公平锁和非公平锁
  10. synchronized 与 wait 和 notify 配合使用,lock 和 condition 和 await,signal 配合使用
  11. lock 是模板方法模式,可以自定义实现锁机制
  12. lock 有读写锁,可以支持同时读读
3.什么是 AQS?

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。

队列同步器 AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成资源获取线程的排队工作。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的 3 个方法

  • getState():获取当前同步状态
  • setState(intnewState):设置当前同步状态
  • compareAndSetState(intexpect,intupdate)

来进行操作,因为它们能够保证状态的改变是安全的。

同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock 和 CountDownLatch 等).

同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义.可以这样理解二者之间的关系:锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作.锁和同步器很好地隔离了使用者和实现者所需关注的领域。

AQS 中有两个重要的成员变量:Node 和 ConditionObject。

Node 有一个模式的属性:独占模式共享模式,独占模式下资源是线程独占的,共享模式下,资源是可以被多个线程占用的。Node 的数据结构其实也挺简单的,就是 thread + waitStatus + pre + next 四个属性而已.

ConditionObject 该类主要是为了让子类实现独占模式。AQS 框架下独占模式的获取资源、释放等操作到最后都是基于这个类实现的。只有在独占模式下才会去使用该类。

node 方法和属性值的含义

方法和属性值含义
waitStatus当前节点在队列中的状态
thread表示处于该节点的线程
prev前驱指针
predecessor返回前驱节点,没有的话抛出 npe
nextWaiter指向下一个处于 CONDITION 状态的节点
next后继指针

线程两种锁的模式:

模式含义
SHARED共享,多个线程可同时执行,如Semaphore/CountDownLatch
EXCLUSIVE独占,只有一个线程能执行,如ReentrantLock
4.AQS 基于什么设计模式实现的?

同步器的设计是基于模板方法模式的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

同步器可重写的方法如下图所示:

方法名称描述
protected boolean tryAcquire(int arg)独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后再进行 CAS 设置同步状态
protected boolean tryRelease(int arg)独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态

实现自定义同步组件时,将会调用同步器提供的模板方法

方法名称描述
protected int tryAcquireShared(int arg)共享式获取同步状态,返回大于等于 0 的值,表示获取成功,反之,获取失败
protected boolean tryReleaseShared(int arg)共享式释放同步状态
protected booleanisHeldExclusively()当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所独占
5.AQS 底层同步队列的原理?

同步器依赖内部的同步队列(一个 FIFO 双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述.

方法名称描述
void acquire(int arg)独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用重写的 tryAcquire(int arg) 方法
void acquireInterruptibly(int arg)与 acquire(intarg)相同,但是该方法响应中断,当前线程未获取到同步状态而进入同步队列中,如果当前线程被中断,则该方法会抛出 InterruptedException 并返回
boolean tryAcquireNanos(int arg,long nanos)在 acquireInterruptibly(int arg)基础上增加了超时限制,如果当前线程在超时时间内没有获取到同步状态,那么将会返回 false,如果获取到了返回 true
void acquireShared(int arg)共享式的获取同步状态,如果当前线程未获取到同步状态,将会进人同步队列等待,与独占式获取的主要区别是在同一时刻可以有多个线程获取到同步状态
void acquireSharedInteruptibly(int arg)与 acquireShared(intarg)相同,该方法响应中断
boolean tryAcquireSharedNanos(int arg,long nanos)在 acquireSharedInterruptibly(intarg)基础上增加了超时限制
boolean release(int arg)独占式的释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒
boolean releaseShared(int arg)共享式的释放同步状态
Collection<Thread>getQueuedThreads获取等待在同步队列上的线程集合

同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部

image-20220415133145679

同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点.试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于 CAS 的设置尾节点的方法:compareAndSetTail(Node expect,Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联

同步队列遵循 FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。

image-20220415133223169

设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用 CAS 来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的 next 引用即可。

6.AQS 独占式同步状态获取与释放?

AQS 的 acquire 方法

public final void acquire(int arg) {
  if (!tryAcquire(arg) &&
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

通过调用同步器的 acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出

上述代码主要完成了同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待的相关工作,其主要逻辑是:首先调用自定义同步器实现的 tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式 Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过 addWaiter(Node node)方法将该节点加入到同步队列的尾部,当出现竞争时,采用 CAS 的方式加入到同步队列的尾部.最后调用 acquireQueued(Node node,int arg)方法,使得该节点以“死循环”的方式获取同步状态.如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。

private Node addWaiter(Node mode) {
  Node node = new Node(Thread.currentThread(), mode);
  // Try the fast path of enq; backup to full enq on failure
  //快速尝试在尾部添加
  Node pred = tail;
  if (pred != null) {
    node.prev = pred;
    if (compareAndSetTail(pred, node)) {
      pred.next = node;
      return node;
    }
  }
  enq(node);
  return node;
}


private Node enq(final Node node) {
  for (;;) {
    Node t = tail;
    if (t == null) { // Must initialize  头节点可能为空
      if (compareAndSetHead(new Node()))
        tail = head;
    } else {
      node.prev = t;
      if (compareAndSetTail(t, node)) {
        t.next = node;
        return t;
      }
    }
  }
}

上述代码通过使用 compareAndSetTail(Node expect,Node update)方法来确保节点能够被线程安全添加。

在 enq(final Node node)方法中,同步器通过“死循环”来保证节点的正确添加,在“死循环”中只有通过 CAS 将节点设置成为尾节点之后,当前线程才能从该方法返回,否则,当前线程不断地尝试设置.可以看出,enq(final Node node)方法将并发添加节点的请求通过 CAS 变得“串行化”了。

final boolean acquireQueued(final Node node, int arg) {
  boolean failed = true;
  try {
    boolean interrupted = false;
    for (;;) {
      final Node p = node.predecessor();
      if (p == head && tryAcquire(arg)) {
        setHead(node);
        p.next = null; // help GC
        failed = false;
        return interrupted;
      }
      if (shouldParkAfterFailedAcquire(p, node) &&
          parkAndCheckInterrupt())
        interrupted = true;
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}

如果前驱节点是头节点,则尝试获取同步锁.而只有前驱节点是头节点才能够尝试获取同步状态,这是为什么?头节点是成功获取到同步状态的节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。如果前驱节点不是头节点,则获取同步锁失败,那么线程继续在同步队列中等待.独占式同步状态获取流程,也就是 acquire(int arg)方法调用流程如下所示:

image-20220415133635466

当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态.通过调用同步器的 release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态).该方法代码如下所示:

public final boolean release(int arg) {
  if (tryRelease(arg)) {
    Node h = head;
    if (h != null && h.waitStatus != 0)
      unparkSuccessor(h);
    return true;
  }
  return false;
}
private void unparkSuccessor(Node node) {

  int ws = node.waitStatus;
  if (ws < 0)
    compareAndSetWaitStatus(node, ws, 0);

  Node s = node.next;
  if (s == null || s.waitStatus > 0) {
    s = null;
    for (Node t = tail; t != null && t != node; t = t.prev)
      if (t.waitStatus <= 0)
        s = t;
  }
  if (s != null)
    LockSupport.unpark(s.thread);
}

该方法执行时,会唤醒头节点的后继节点线程, unparkSuccessor(Node node)方法使用 LockSupport 来唤醒处于等待状态的线程。

image-20220609181354585

7.共享式同步状态获取与释放?

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态.以文件的读写为例,如果一个程序在对文件进行读操作,那么这一时刻对于该文件的写操作均被阻塞,而读操作能够同时进行。在 acquireShared(int arg)方法中,同步器调用 tryAcquireShared(int arg)方法尝试获取同步状态,tryAcquireShared(int arg)方法返回值为 int 类型,当返回值大于等于 0 时,表示能够获取到同步状态。因此,在共享式获取的自旋过程中,成功获取到同步状态并退出自旋的条件就是 tryAcquireShared(intarg)方法返回值大于等于 0。在 doAcquireShared(int arg)方法的自旋过程中,如果当前节点的前驱为头节点时,尝试获取同步状态,如果返回值大于等于 0,表示该次获取同步状态成功并从自旋过程中退出。

该方法在释放同步状态之后,将会唤醒后续处于等待状态的节点.对于能够支持多个线程同时访问的并发组件,它和独占式主要区别在于 tryReleaseShared(int arg)方法必须确保同步状态(或者资源数)线程安全释放,一般是通过循环和 CAS 来保证的,因为释放同步状态的操作可能会同时来自多个线程。

//获取同步状态
public final void acquireShared(int arg) {
  if (tryAcquireShared(arg) < 0)
    doAcquireShared(arg);
}

private void doAcquireShared(int arg) {
  final Node node = addWaiter(Node.SHARED);
  boolean failed = true;
  try {
    boolean interrupted = false;
    for (;;) {
      final Node p = node.predecessor();
      if (p == head) {
        int r = tryAcquireShared(arg);
        if (r >= 0) {
          setHeadAndPropagate(node, r);
          p.next = null; // help GC
          if (interrupted)
            selfInterrupt();
          failed = false;
          return;
        }
      }
      if (shouldParkAfterFailedAcquire(p, node) &&
          parkAndCheckInterrupt())
        interrupted = true;
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}

private void doAcquireSharedInterruptibly(int arg)
  throws InterruptedException {
  final Node node = addWaiter(Node.SHARED);
  boolean failed = true;
  try {
    for (;;) {
      final Node p = node.predecessor();
      if (p == head) {
        int r = tryAcquireShared(arg);
        if (r >= 0) {
          setHeadAndPropagate(node, r);
          p.next = null; // help GC
          failed = false;
          return;
        }
      }
      if (shouldParkAfterFailedAcquire(p, node) &&
          parkAndCheckInterrupt())
        throw new InterruptedException();
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}

//释放同步状态
private void doReleaseShared() {
  for (;;) {
    Node h = head;
    if (h != null && h != tail) {
      int ws = h.waitStatus;
      if (ws == Node.SIGNAL) {
        if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
          continue; // loop to recheck cases
        unparkSuccessor(h);
      }
      else if (ws == 0 &&
               !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
        continue;// loop on failed CAS
    }
    if (h == head) // loop if head changed
      break;
  }
}
8.AQS 独占式超时获取锁和可中断获取锁?

在 Java 5 之前,当一个线程获取不到锁而被阻塞在 synchronized 之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改,但线程依旧会阻塞在 synchronized 上,等待着获取锁.在 Java 5 中,同步器提供了 acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出 InterruptedException。

超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”, doAcquireNanos(int

arg,long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的特性.针对超时获取,主要需要计算出需要睡眠的时间间隔 nanosTimeout,为了防止过早通知, nanosTimeout 计算公式为: nanosTimeout-=now-lastTime,其中 now 为当前唤醒时间, lastTime 为上次唤醒时间,如果 nanosTimeout 大于 0 则表示超时时间未到,需要继续睡眠 nanosTimeout 纳秒,反之,表示已经超时。

private boolean doAcquireNanos(int arg, long nanosTimeout)
  throws InterruptedException {
  if (nanosTimeout <= 0L)
    return false;
  final long deadline = System.nanoTime() + nanosTimeout;
  final Node node = addWaiter(Node.EXCLUSIVE);
  boolean failed = true;
  try {
    for (;;) {
      final Node p = node.predecessor();
      if (p == head && tryAcquire(arg)) {
        setHead(node);
        p.next = null; // help GC
        failed = false;
        return true;
      }
      nanosTimeout = deadline - System.nanoTime();
      if (nanosTimeout <= 0L)
        return false;
      if (shouldParkAfterFailedAcquire(p, node) &&
          nanosTimeout > spinForTimeoutThreshold)
        LockSupport.parkNanos(this, nanosTimeout);
      if (Thread.interrupted())
        throw new InterruptedException();
    }
  } finally {
    if (failed)
      cancelAcquire(node);
  }
}

如果 nanosTimeout 小于等于 spinForTimeoutThreshold (1000 纳秒)时,将不会使该线程进行超时等待,而是进入快速的自旋过程.原因在于,非常短的超时等待无法做到十分精确,如果这时再进行超时等待,相反会让 nanosTimeout 的超时从整体上表现得反而不精确.因此,在超时非常短的场景下,同步器会进入无条件的快速自旋。

image-20220609183642370

9.AQS 实现的工具类?
  • AbstractQueuedSynchronizer (java.util.concurrent.locks)

    • CountDownLatch (java.util.concurrent)

    • ThreadPoolExecutor (java.util.concurrent)

    • LimitLatch (org.apache.tomcat.util.threads)

    • ReentrantLock (java.util.concurrent.locks)

    • ReentrantReadWriteLock (java.util.concurrent.locks)

    • Semaphore (java.util.concurrent)

10.tryAcquireShared(int)函数返回值?
  • 负数:表示获取失败;
  • 0:获取成功,但没有剩余资源;
  • 正数:获取成功,且有剩余资源;
11.什么是可重入锁?

首先明确下 synchronized 和 lock 接口均为可重入锁。

重入锁,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。

实现原理如下:

  • 线程再次获取锁.锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  • 锁的最终释放.线程重复 n 次获取了锁,随后在第 n 次释放该锁后,其他线程能够获取到该锁.锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于 0 时表示锁已经成功释放。
12.ReentrantLock 的非公平锁的获取与释放?

使用非公平锁加锁过程:

  • ReentrantLock:lock()。
  • NonfairSync:lock()。
  • AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
final boolean nonfairTryAcquire(int acquires){
  final Thread current = Thread.currentThread();
  int c = getState();
  if (c == 0){
    if (compareAndSetState(0, acquires)){
      setExclusiveOwnerThread(current);//以cas原子操作更新state
      return true;
    }
  } else if (current == getExclusiveOwnerThread()){
    int nextc = c + acquires;
    if (nextc < 0)// overflow
      throw new Error("Maximum lock count exceeded");
    setState(nextc);
    return true;
  }
  return false;
}

成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求 ReentrantLock 在释放同步状态时减少同步状态值

在使用非公平锁时,解锁方法 unlock()调用轨迹如下。

  1. ReentrantLock:unlock()
  2. AbstractQueuedSynchronizer:release(int arg)。
  3. Sync:tryRelease(int releases)。
protected final boolean tryRelease(int releases){
  int c = getState()- releases;
  if (Thread.currentThread()!= getExclusiveOwnerThread())
    throw new IllegalMonitorStateException();
  boolean free = false;
  if (c == 0){
    free = true;
    setExclusiveOwnerThread(null);
  }
  setState(c);
  return free;
}
13.ReentrantLock 公平锁的获取和释放?

公平锁就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照 FIFO 的规则从队列中取到自己。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大。

使用公平锁时,加锁方法 lock()调用轨迹如下。

  1. ReentrantLock:lock()。
  2. FairSync:lock()
  3. AbstractQueuedSynchronizer:acquire(int arg)。
  4. ReentrantLock:tryAcquire(int acquires)。

在第 4 步真正开始加锁,下面是该方法的源代码。

在使用公平锁时,解锁方法 unlock()调用轨迹如下。

  1. ReentrantLock:unlock()
  2. AbstractQueuedSynchronizer:release(int arg)。
  3. Sync:tryRelease(int releases)。

在第 3 步真正开始释放锁,下面是该方法的源代码。

c==0 代表是第一次进入,不是重复获取锁,所以不需要加其他的判断,第一步需要读取 volatile 变量 state

公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是 FIFO。该方法与 nonfairTryAcquire(int acquires)比较,唯一不同的置为判断条件多了 hasQueuedPredecessors()方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回 true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁

static final class FairSync extends Sync {
  private static final long serialVersionUID = -3000897897090466540L;

  final void lock(){
    acquire(1);
  }
  protected final boolean tryAcquire(int acquires){
    final Thread current = Thread.currentThread();
    int c = getState();//获取锁,读取volatile变量state
    if (c == 0){
      if (!hasQueuedPredecessors()&&
          compareAndSetState(0, acquires)){
        setExclusiveOwnerThread(current);
        return true;
      }
    } else if (current == getExclusiveOwnerThread()){
      int nextc = c + acquires;
      if (nextc < 0)
        throw new Error("Maximum lock count exceeded");
      setState(nextc);
      return true;
    }
    return false;
  }
}

释放锁的过程和非公平锁一样

protected final boolean tryRelease(int releases){
  int c = getState()- releases;
  if (Thread.currentThread()!= getExclusiveOwnerThread())
    throw new IllegalMonitorStateException();
  boolean free = false;
  if (c == 0){
    free = true;
    setExclusiveOwnerThread(null);
  }
  setState(c);//释放锁,写volatile变量state
  return free;
}
14.公平锁和非公平锁总结?
  • 公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。
  • 公平锁获取时,首先会去读 volatile 变量,并且判断是否有前驱结点.
  • 非公平锁获取时,首先会用 CAS 更新 volatile 变量,这个操作同时具有 volatile 读和 volatile 写的内存语义。
  • 非公平锁获取同步状态失败后也需要放置到队列中.
15.为什么非公平锁会造成线程饥饿?

首先说下公平锁.假设目前 AQS 的同步队列中有 A B C 三个线程.线程 A 排在最前边.当线程 A 获取锁的同时,线程 D 也要获取锁.此时 D 线程先会通过 tryAcquire 方法判断是否自己是同步队列的头结点,如果不是,则乖乖的去同步队列中等待.线程 A 处于无竞争状态下获取锁.因此说公平锁完全按照线程先后进行 FIFO 的获取锁。非公平锁不必唤醒所有线程,cpu 开销小.

  1. ReentrantLock:lock()。

  2. FairSync:lock()。

  3. AbstractQueuedSynchronizer:acquire(int arg)。

  4. ReentrantLock:tryAcquire(int acquires)

再说非公平锁.假设目前 AQS 的同步队列中有 A B C 三个线程.线程 A 排在最前边.当线程 A 获取锁的同时,线程 D 也要获取锁.此时 D 线程直接进行争夺,虽说 D 是后来的,但是作为非公平锁,会直接进行 cas 的竞争.如果竞争成功,线程 A 继续作为头结点,等待 D 线程释放锁,参与下一轮竞争.如果竞争失败,线程 D 也需要乖乖的到同步队列中排队。从这段话我们可以看到,如果线程 A 一直竞争不到锁,那么就会一直留在同步队列中等待,造成线程饥饿,没事儿可干。

使用非公平锁时,加锁方法 lock()调用轨迹如下。

  1. ReentrantLock:lock()。
  2. NonfairSync:lock()。
  3. AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
16.什么是读写锁?

读写锁,读读不排他,读写排他,写写排他.。Java 并发包提供读写锁的实现是 ReentrantReadWriteLock,它提供的特性如下所示:

特性说明
公平性选择支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平
重进入该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也可以获取读锁
锁降级遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁

ReentrantReadWriteLock 作为 ReadWriteLock 的子类.ReadWriteLock 仅定义了获取读锁和写锁的两个方法,即 readLock()方法和 writeLock()方法

public interface ReadWriteLock {
  /**
* Returns the lock used for reading
*@return the lock used for reading
*/
  Lock readLock();

  /**
* Returns the lock used for writing
*@return the lock used for writing
*/
  Lock writeLock();
}

而其实现 ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,这些方法以及描述如下所示。

方法名称描述
int getReadLockCount()返回当前读锁被获取的次数。该次数不等于获取读锁的线程数,例如,仅一个线程,它连续获取(重进入)了 n 次读锁,那么占据读锁的线程数是 1.但该方法返回 n
int getReadHoldCount()返回当前线程获取读锁的次数。该方法在 Java6 中加入到 ReentrantReadWriteLock 中,使用 ThreadLocal 保存当前线程获取的的次数,这也使得 Java6 的实现变得更加复杂
boolean isWriteLocked()判断写锁是否被获取
int getWriteHoldCount()返回当前写锁被获取的次数

如下代码,Cache 组合一个非线程安全的 HashMap 作为缓存的实现,同时使用读写锁的读锁和写锁来保证 Cache 是线程安全的.在读操作 get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作 put(String key,Object value)方法和 clear()方法,在更新 HashMap 必须提前获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,而只有写锁被释放之后,其他读写操作才能继续.Cache 使用读写锁提升读操作的并发性,也保证每次操作对所有的读写操作的可见性,同时简化了编程方式。

public class Cache {
  static Map<String, Object> map = new HashMap<String, Object>();
  static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  static Lock r = rwl.readLock();
  static Lock w = rwl.writeLock();

  //获取一个key对应的value
  public static final Object get(String key){
    r.lock();
    try {
      return map.get(key);
    } finally {
      r.unlock();
    }
  }

  //设置key对应的value,并返回旧的value
  public static final Object put(String key, Object value){
    w.lock();
    try {
      return map.put(key, value);
    } finally {
      r.unlock();
    }
    return null;
  }

  //清空所有的内容
  public static final void clear(){
    w.lock();
    try {
      map.clear();
    } finally {
      w.unlock();
    }
  }
}
17.ReentrantReadWriteLock 实现原理?

读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态 state.回想 ReentrantLock 中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。

如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写,划分方式如下图所示:

image-20220415163159873

18.ReentrantReadWriteLock 写锁的获取与释放?

写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,**读锁已经被获取(读状态不为 0)**或者当前线程不是已经获取写锁的线程(意思是非重入状态),则当前线程进入等待状态:

protected final boolean tryAcquire(int acquires){
  Thread current = Thread.currentThread();
  int c = getState();//读锁和写锁的总体状态
  int w = exclusiveCount(c);//写锁状态
  if (c != 0){
    //(Note: if c != 0 and w == 0 then shared count != 0)
    //存在读锁或者当前获取线程不是已经获取写锁的线程
    if (w == 0 || current != getExclusiveOwnerThread())
      return false;
    if (w + exclusiveCount(acquires)> MAX_COUNT)
      throw new Error("Maximum lock count exceeded");
    // Reentrant acquire
    setState(c + acquires);
    return true;
  }
  if (writerShouldBlock()||
      !compareAndSetState(c, c + acquires))
    return false;
  setExclusiveOwnerThread(current);
  return true;
}

从代码上看,如果 c!= 0,w=0。说明 r!=0。则 return false,当前线程进入同步队列等待。否则进行加锁,更改 state 的值,如果 c==0,这说明读写锁都没有。则进行判断是否写线程需要 block 或者进行更新同步状态失败。否则设置当前线程为 owner 线程,获取锁成功。写锁的释放与 ReentrantLock 的释放过程基本类似,每次释放均减少写状态,当写状态为 0 时表示写锁已被释放.

19.ReentrantReadWriteLock 读锁的获取与释放?

读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为 0)时,读锁总会被成功地获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。类似于修改文件的时候不能打开查看.

protected final int tryAcquireShared(int unused){

  Thread current = Thread.currentThread();
  int c = getState();//读写总状态
  //判断是否有其他线程获取到写锁
  if (exclusiveCount(c)!= 0 &&
      getExclusiveOwnerThread()!= current)
    return -1;
  int r = sharedCount(c);//读状态
  if (!readerShouldBlock()&&
      r < MAX_COUNT &&
      compareAndSetState(c, c + SHARED_UNIT)){
    if (r == 0){
      firstReader = current;
      firstReaderHoldCount = 1;
    } else if (firstReader == current){
      firstReaderHoldCount++;
    } else {
      HoldCounter rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current))
        cachedHoldCounter = rh = readHolds.get();
      else if (rh.count == 0)
        readHolds.set(rh);
      rh.count++;//读重入
    }
    return 1;
  }
  return fullTryAcquireShared(current);
}

在 tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是(1<<16)。

20.ReentrantReadWriteLock 的锁降级问题?

锁降级指的是写锁降级成为读锁.如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级.锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。state 是包含读写锁的状态,保证安全性.

21.为什么先获取读锁才能释放写锁?

因为 state 是包含读写锁的状态,如果先释放写锁,则获取读锁的时候,写锁被其他线程获取,不能再次获取到读锁.而拥有写锁的时候可以同时获取读锁,所以需要先获取读锁才能释放写锁.原理上来说只能有一个线程持有写锁.

22.RentrantReadWriteLock 为什么不支持锁升级?

读锁升级为写锁。首先自己有读锁,之后拿到写锁。如果有两个读锁同时升级为写锁。那么只有一个能升级成功。但是这两个线程同时拥有读锁。其中一个线程还一直在申请写锁。其他线程获取到读锁时,不能获取写锁.这就会造成死锁。但是锁降级是可以的。因为写锁只有一个线程占有。

23.LockSupport 工具是什么?

当需要阻塞或唤醒一个线程的时候,都会使用 LockSupport 工具类来完成相应工作.LockSupport 定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而 LockSupport 也成为构建同步组件的基础工具.(对比使用 synchronized 方法的阻塞唤醒功能.).LockSupport 定义了一组以 park 开头的方法用来阻塞当前线程,以及 unpark(Thread thread)方法来唤醒一个被阻塞的线程。

方法名称描述
void park()阻塞当前线程,如果调用 unpar ik(Thread thread)方法或者当前线程被中断,才能从 park()方法返回
void parkNanos(long nanos)阻塞当前线程,最长不超过 na anos 纳秒,返回条件在 park(的基础上增加了超时返回
void parkUntil(long deadline)阻塞当前线程,直到 deadline 日时间(从 1970 年开始到 deadline 时间的毫秒数)
void unpark(Thread thread)唤醒处于阻塞状态的线程 thread

先唤醒线程,再阻塞线程,线程不会真的阻塞;但是先唤醒线程两次再阻塞两次时就会导致线程真的阻塞。

LockSupport 就是通过控制变量_counter来对线程阻塞唤醒进行控制的。原理有点类似于信号量机制。

  • 当调用park()方法时,会将_counter 置为 0,同时判断前值,小于 1 说明前面被unpark过,则直接退出,否则将使该线程阻塞。
  • 当调用unpark()方法时,会将_counter 置为 1,同时判断前值,小于 1 会进行线程唤醒,否则直接退出。
    形象的理解,线程阻塞需要消耗凭证(permit),这个凭证最多只有 1 个。当调用 park 方法时,如果有凭证,则会直接消耗掉这个凭证然后正常退出;但是如果没有凭证,就必须阻塞等待凭证可用;而 unpark 则相反,它会增加一个凭证,但凭证最多只能有 1 个。
  • 为什么可以先唤醒线程后阻塞线程?
    因为 unpark 获得了一个凭证,之后调用 park 因为有凭证消费,故不会阻塞。
  • 为什么唤醒两次后阻塞两次会阻塞线程。
    因为凭证的数量最多为 1,连续调用两次 unpark 和调用一次 unpark 效果一样,只会增加一个凭证;而调用两次 park 却需要消费两个凭证。
24.parkNanos(long nanos)和 parkNanos(Object blocker,long nanos)异同?

对比 parkNanos(long nanos)方法和 parkNanos(Object blocker,long nanos)方法在使用场景上有什么不同?

LockSupport 增加了 3 个方法

  • park(Object blocker)
  • parkNanos(Object blocker,long nanos)
  • parkUntil(Object blocker,long deadline)

用于实现阻塞当前线程的功能,其中参数 blocker 是用来标识当前线程在等待的对象(以下称为阻塞对象),该对象主要用于问题排查和系统监控。

image-20220418155623994

从右图的线程 dump 结果可以看出,代码片段的内容都是阻塞当前线程 10 秒,但从线程 dump 结果可以看出,有阻塞对象的 parkNanos 方法能够传递给开发人员更多的现场信息.这是由于在 Java 5 之前,当线程阻塞(使用 synchronized 关键字)在一个对象上时,通过线程 dump 能够查看到该线程的阻塞对象,方便问题定位,而 Java 5 推出的 Lock 等并发工具时却遗漏了这一点,致使在线程 dump 时无法提供阻塞对象的信息.因此,在 Java 6 中,LockSupport 新增了上述 3 个含有阻塞对象的 park 方法,用以替代原有的 park 方法。

25.Lock 锁的 Condition 接口是做什么的?

任意一个 Java 对象,都拥有一组监视器方法(定义在 java.lang.Object 上),主要包括 wait()、 wait(long timeout)、 notify()以及 notifyAll()方法,这些方法与 synchronized 同步关键字配合,可以实现等待/通知模式.Condition 接口也提供了类似 Object 的监视器方法,与 Lock 配合可以实现等待/通知模式。

ConditionObject 是 AQS 的一个内部类,Condition 操作需要获取同步状态.节点类型和 AQS 中的节点是一样的.

image-20220609194933608

通过对比 Object 的监视器方法和 Condition 接口,可以更详细地了解 Condition 的特性

Object 的监视器方法与 Condition 接口的对比

对比项Obiect Monitor MethodsCondition
前置条件获取对象的锁调用 Locklock 获取锁
调用 LocknewCondition 获取 Condition 对象
调用方式直接调用
如:object.wait()
直接调用
如:condition.await()
等待队列个数一个多个
当前线程释放锁并进人等待状态支持支持
当前线程释放锁并进人等待状态,在等待状态中不响应中断不支持支持
当前线程释放锁并进入超时等待状态支持支持
当前线程释放锁并进入等待状态到将来的某个时间不支持支持
唤醒等待队列中的一个线程支持支持
唤醒等待队列中的全部线程支持支持

Condition 定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到 Condition 对象关联的锁.Condition 对象是由 Lock 对象(调用 Lock 对象的 newCondition()方法)创建出来的,换句话说,Condition 是依赖 Lock 对象的。

public class Juc_15_keyword_Condition {
    final Lock lock = new ReentrantLock();//定义锁对象
    //通过锁对象获取Condition实例
    final Condition notFull = lock.newCondition(); //用于控制put操作
    final Condition notEmpty = lock.newCondition(); //用于控制take操作
    final Object[] items = new Object[100];//缓冲队列,初始容量100
    int putptr, takeptr, count;//分别记录put,take当前的索引,count用于记录当前item的个数

    /**
     * 往缓冲队列中添加数据
     */
    public void put(Object x) throws InterruptedException {
        //上锁,作用和synchronized一样,保证代码同一时刻只有一个线程可以操作,也保证了和take方法的互斥
        lock.lock();
        try {
            while (count == items.length) {
                notFull.await();//如果队列满了,则put线程等待被唤醒
            }
            items[putptr] = x; //队列未满,则添加数据
            if (++putptr == items.length) putptr = 0;//添完后,如果记录读数据的索引到了最后一个位置,则重置为0
            ++count;//item总数自增
            notEmpty.signal();//唤醒take线程取数据
        } finally {
            lock.unlock();//put操作完后,释放锁.
        }
    }

    /**
     * 从缓冲队列中取数据
     */
    public Object take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0) {
                notEmpty.await();//如果队列空了,则take线程等待被唤醒
            }
            Object x = items[takeptr]; //队列未空,则取数据
            if (++takeptr == items.length) takeptr = 0;//取完后,如果记录取数据的索引到了最后一个位置,则重置为0
            --count;//item总数自减
            notFull.signal();//唤醒put线程添加数据
            return x;//返回取得的数据
        } finally {
            lock.unlock();//take操作完后,释放锁对象
        }
    }
}

当调用 await()方法后,当前线程会释放锁并在此等待,而其他线程调用 Condition 对象的 signal()方法,通知当前线程后,当前线程才从 await()方法返回,并且在返回前已经获取了锁。一个线程进入等待状态----释放锁----线程被唤醒----线程获取锁----等待状态结束。

Condition 相关的方法,Condition 其实就是一个队列,好理解

方法描述
void await()造成当前线程在接到信号或被中断之前一直处于等待状态。
boolean await(long time, TimeUnit unit)造成当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
long awaitNanos(long nanosTimeout)造成当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。
void awaitUninterruptibly()造成当前线程在接到信号之前一直处于等待状态。
boolean awaitUntil(Date deadline)造成当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态。
void signal()唤醒一个等待线程。
void signalAll()唤醒所有等待线程。
26.Condition 的实现原理?

ConditionObject 是同步器 AbstractQueuedSynchronizer 的内部类,因为 Condition 的操作需要获取相关联的锁,所以作为同步器的内部类也较为合理.每个 Condition 对象都包含着一个队列(以下称为等待队列),该队列是 Condition 对象实现等待/通知功能的关键。

如果一个线程调用了 Condition.await()方法,那么该线程将会释放锁、构造成节点加入等待队列并进入等待状态。

一个 Condition 包含一个等待队列,Condition 拥有首节点(firstWaiter)和尾节点(lastWaiter).当前线程调用 Condition.await()方法,将会以当前线程构造节点,并将节点从尾部加入等待队列

Condition 拥有首尾节点的引用,而新增节点只需要将原有的尾节点 nextWaiter 指向它,并且更新尾节点即可.上述节点引用更新的过程并没有使用 CAS 保证,原因在于调用 await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证线程安全的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G61Dxtm4-1660370613969)(https://s2.loli.net/2022/04/18/edSB6IAiOntfWyQ.png)]

在 Object 的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的 Lock (更确切地说是同步器)拥有一个同步队列和多个等待队列

image-20220418160040057

如果从队列(同步队列和等待队列)的角度看 await()方法,当调用 await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到 Condition 的等待队列中。

//AbstractQueuedLongSynchronizer中的内部类ConditionObject,实现了Condition接口
public final void signal() {
  if (!isHeldExclusively())
    throw new IllegalMonitorStateException();
  Node first = firstWaiter;
  if (first != null)
    doSignal(first);
}

调用 Condition 的 signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中.调用该方法的前置条件是当前线程必须获取了锁,可以看到 signal()方法进行了 isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程.接着获取等待队列的首节点,将其移动到同步队列并使用 LockSupport 唤醒节点中的线程。

Condition 的 signalAll()方法,相当于对等待队列中的每个节点均执行一次 signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。唤醒代表着移动到同步队列中.

27.ReentrantLock 使用和理解?

在 ReentrantLock 中,调用 lock()方法获取锁;调用 unlock()方法释放锁。

注意:lock()是在 try 的外面,为了防止获取锁失败,还去释放锁.

ReentrantLock 的实现依赖于 Java 同步器框架 AbstractQueuedSynchronizer(本文简称之之为 AQS)。AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。

ReentrantLock 默认是非公平锁,如果需要公平锁,参数传入 true

image-20220425184350325

28.park unpark 原理

每个线程都有自己的一个 Parker 对象,由三部分组成 _counter _cond _mutex

打个比喻线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)

  • 调用 park 就是要看需不需要停下来歇息

    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用 unpark,就好比令干粮充足

    • 如果这时线程还在帐篷,就唤醒让他继续前进
    • 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进

因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮

四.并发工具

1.JUC 包概述?

juc 是 java.util.concurrent 的简称,为了支持高并发任务,在编程时可以有效减少竞争条件和死锁线程.

juc 主要包含 5 大工具包

  • locks
    • ReentrantLock:它是独占锁,是指只能被独自占领,即同一个时间点只能被一个线程锁获取到的锁。
    • ReentrantReadWriteLock:子类 ReadLock 和 WriteLock。ReadLock 是共享锁,而 WriteLock 是独占锁。
    • LockSupport:它具备阻塞线程和解除阻塞线程的功能,并且不会引发死锁。
  • executor
    • 线程池的顶级接口
    • ExecutorService
      • ScheduledExecutorService 需要重复执行的任务
      • ScheduledThreadPoolExecutor 周期性调度任务
  • tools
    • CountDownLatch:等待其他的线程都执行完任务,三二一,芝麻开门
    • CyclicBarrier:需要线程都到达某个公共的屏障点,可以重用
    • Semaphore:计数信号量,本质是共享锁,通过调用 acquire()来获取信号量的许可,通过 release()来释放它所持有的信号量许可.
  • atomic:原子操作的类,cas 操作,用 volatile 修饰类型变量 value
    • AtomicBoolean
    • AtomicInteger
  • collections:主要提供线程安全的集合
    • HashMap 对应的高并发类是 ConcurrentHashMap
    • ArrayList 对应的高并发类是 CopyOnWriteArrayList
    • HashSet 对应的高并发类是 CopyOnWriteArraySet
    • Queue 对应高并发类是 ConcurrentLinkedQueue
    • SimpleDateFormat 对应高并发类是 FastDateFormat 或者 DateFormatUtils

底层依赖—>中层工具—>具体实现

  • 首先,声明共享变量为 volatile。
  • 然后,使用 CAS 的原子条件更新来实现线程之间的同步。
  • 同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存
    语义来实现线程之间的通信。

image-20220425191023087

2.为什么要使用 ConcurrentHashMap?

ConcurrentHashmap 存在的原因:

  • HashMap 线程不安全的 map,多线程环境下 put 操作会出现死循环.会导致 Entry 链表变为环形结构.next 节点用不为空,就成了死循环获取 Entry.
  • HashTable 效率低下
  • ConcurrentHashMap 锁分段可以提高并发访问效率

ConcurrentHashmap 和 HashMap 区别:

  • HashMap 是非线程安全的,而 HashTable 和 ConcurrentHashMap 都是线程安全的

  • HashMap 的 key 和 value 均可以为 null;而 HashTable 和 ConcurrentHashMap 的 key 和 value 均不可以为 null

  • HashTable 和 ConcurrentHashMap 的区别:保证线程安全的方式不同

3.ConcurrentHashMap 数据结构?

jdk1.8 之前的数据结构:

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结合组成。默认有 16 个区段。Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组。Seqment 的结构和 HashMap 类似,是一种数组和链表结构。一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得与它对应的 Segment 锁.

不同线程操作不同的 Segment 就不会出现安全问题,性能上大提升.在 jdk1.8 之前,多线程操作同一个 Segment 不能并发,在 jdk1.8 优化了.

image-20220610114138091

jdk1.8 之后的数据结构:

改进:没有 Segment 区段了,和 HashMap 一致了,数组+链表+红黑树 +乐观锁 + synchronized

ConcurrentHashMap 数据结构与 1.8 中的 HashMap 保持一致,均为数组+链表+红黑树,是通过乐观锁+Synchroni zed 来保证线程安全的.当多线程并发向同一个散列桶添加元素时。

若散列桶为空,此时触发乐观锁机制,线程会获取到桶中的版本号,在添加节点之前,判断线程中获取的版本号与桶中实际存在的版本号是否一致,若一致,则添加成功,若不一致,则让线程自旋。

若散列桶不为空,此时使用 Synchronized 来保证线程安全,先访问到的线程会给桶中的头节点加锁,从而保证线程安全。

4.ConcurrentHashMap 的初始化?

ConcurrentHashMap 初始化方法是通过 initialCapacity、 loadFactor 和 concurrencyLevel 等几个参数来初始化 segment 数组、段偏移量 segmentShift、段掩码 segmentMask 和每个 segment 里的 HashEntry 数组来实现的。

段偏移量 segmentShift:用于定位参与散列运算的位数.

段掩码 segmentMask:散列运算的掩码.掩码的二进制各个位都是 1

默认情况下 initialCapacity 为 16,loadFactor 为 0.75

segments 数组的长度 ssize 是通过 concurrencyLevel 计算得出的。为了能通过按位与的散列算法来定位 segments 数组的索引,必须保证 segments 数组的长度是 2 的 N 次方(power-of-two size),所以必须计算出一个大于或等于 concurrencyLevel 的最小的 2 的 N 次方值来作为 segments 数组的长度。假如 concurrencyLevel 等于 14、15 或 16,ssize 都会等于 16,即容器里锁的个数也是 16。
注意 concurrencyLevel 的最大值是 65535,这意味着 segments 数组的长度最大为 65536,对应的二进制是 16 位。

public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
  if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
    throw new IllegalArgumentException();
  if (initialCapacity < concurrencyLevel)   // Use at least as many bins
    initialCapacity = concurrencyLevel;   // as estimated threads
  long size = (long)(1.0 + (long)initialCapacity / loadFactor);
  int cap = (size >= (long)MAXIMUM_CAPACITY) ?
    MAXIMUM_CAPACITY : tableSizeFor((int)size);
  this.sizeCtl = cap;
}
5.定位数据?

首先需要找到 segment,通过散列算法对 hashcode 进行再散列找到 segment,主要是为了分布更加均匀,否则都在一个 segment 上,起不到分段锁的作用.散列函数如下,为了让高位参与到运算中.

final Segment<K,V> segmentFor(int hash) {
  return segments[(hash >>> segmentShift)&segmentMask];
}
6.ConcurrentHashMap 的 get 操作?
transient volatile int count;
volatile V value;

public V get(Object key) {
  int hash =hash(key.hashCode());//先进行一次再散列
  return segmentFor(hash).get(key,hash);//再通过散列值定位到segment
  //最后通过散列获取到元素
}

get 操作的高效之处在于整个 get 过程不需要加锁。原因是它的 get 方法里将要使用的共享变量都定义成 volatile 类型
如用于统计当前 Segement 大小的 count 字段和用于存储值的 HashEntry 的 value。定义成 volatile 的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写,在 get 操作里只需要读不需要写共享变量 count 和 value,所以可以不用加锁。之所以不会读到过期的值,是因为根据 Java 内存模型的 happen before 原则,对 volatile 字段的写入操作先于读操作,即使两个线程同时修改和获取 volatile 变量,get 操作也能拿到最新的值,这是用 volatile 替换锁的经典应用场景。

hash >>> segmentShift)&segmentMask // 定位Segment所使用的hash 算法
int index= hash &(tab.length -1); // 定位HashEntry所使用的hash算法
7.ConcurrentHashMap 的 put 操作?

put 方法必须加锁,需要做 2 件事

  • 是否扩容
  • 查找插入位置

在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效, ConcurrentHashMap 不会对整个容器进行扩容,而只对某个 segment 进行扩容。数据量相对较小,注意和 HashMap 的区别.

8.ConcurrentHashMap 的 size 操作?

因为在累加 count 操作过程中,之前累加过的 count 发生变化的几率非常小,所以 ConcurrentHashMap 的做法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有 Segment 的大小。那么 ConcurrentHashMap 是如何判断在统计的时候容器是否发生了变化呢?使用 modCount 变量,在 put、remove 和 clean 方法里操作元素前都会将变量 modCount 进行加 1,那么在统计 size 前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。

9.说说 ConcurrentLinkedQueue?

线程安全的非阻塞队列 ConcurrentLinkedQueue

public boolean offer(E e) {
  checkNotNull(e);
  final Node<E> newNode = new Node<E>(e);

  for (Node<E> t = tail, p = t;;) {
    Node<E> q = p.next;
    if (q == null) {//p是最后一个节点
      if (p.casNext(null, newNode)) {//通过cas替换节点p的next节点
        if (p != t) // 是否通过cas替换tail的指向
          casTail(t, newNode);  // Failure is OK.
        return true;
      }
    }
    else if (p == q)//tail未初始化
      p = (t != (t = tail)) ? t : head;
    else
      p = (p != t && t != (t = tail)) ? t : q;
  }
}

Tail 节点并不总是尾节点,所以每次入队都必须先通过 tail 节点来找到尾节点.尾节点可能是 tail 节点,也可能是 tail 节点的 next 节点。因为存在并发插入的情况.

让 tail 节点永远作为队列的尾节点,这样实现代码量非常少,而且逻辑清晰和易懂.但是,这么做有个缺点,每次都需要使用循环 CAS 更新 tail 节点.如果能减少 CAS 更新 tail 节点的次数,就能提高入队的效率,所以并不是每次节点入队后都将 tail 节点更新成尾节点,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,因为从本质上来看它通过增加对 volatile 变量的读操作来减少对 volatile 变量的写操作,而对 volatile 变量的写操作开销要远远大于读操作,所以入队效率会有所提升

public E poll() {
  restartFromHead:
  for (;;) {
    for (Node<E> h = head, p = h, q;;) {
      E item = p.item;
      if (item != null && p.casItem(item, null)) {
        if (p != h)
          updateHead(h, ((q = p.next) != null) ? q : p);
        return item;
      }
      else if ((q = p.next) == null) {
        updateHead(h, p);
        return null;
      }
      else if (p == q)
        continue restartFromHead;
      else
        p = q;
    }
  }
}

首先获取头节点的元素,然后判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用 CAS 的方式将头节点的引用设置成 null,如果 CAS 成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了 head 节点,导致元素发生了变化,需要重新获取头节点。

10.阻塞队列(BlockingQueue)的实现原理?
方法1234
插入方法add(e)offer(e)put(e)offer(e,time,unit)
移除方法remove()poll()take()poll(time,unit)
检查方法element()peek()

阻塞队列使用 Lock+多个 Condition 实现的 FIFO 的队列.多线程环境下安全的,如果队列满了,放入元素的线程会被阻塞,如果队列空了,取元素的线程会被阻塞.具体原理一起看源代码。通过 Condition 来实现队列元素的阻塞,是空还是满.

public ArrayBlockingQueue(int capacity, boolean fair) {
  if (capacity <= 0)
    throw new IllegalArgumentException();
  this.items = new Object[capacity];
  lock = new ReentrantLock(fair);
  notEmpty = lock.newCondition();
  notFull =  lock.newCondition();
}

JDK 7 提供了 7 个阻塞队列,如下。

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。

  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。

  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。

  • DelayQueue:一个使用优先级队列实现的无界阻塞队列。

  • SynchronousQueue:一个不存储元素的阻塞队列。

  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

ArrayBlockingQueue 是一个用数组实现的有界阻塞队列.此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况使用的是非公平锁。

LinkedBlockingQueue 是一个用链表实现的有界阻塞队列.此队列的默认和最大长度为 Integer.MAX_VALUE.此队列按照先进先出的原则对元素进行排序。

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列.默认情况下元素采取自然顺序升序排列.也可以自定义类实现 compareTo()方法来指定元素排序规则,或者初始化 PriorityBlockingQueue 时,指定构造参数 Comparator 来对元素进行排序。

DelayQueue 是一个支持延时获取元素的无界阻塞队列.队列使用 PriorityQueue 来实现.队列中的元素必须实现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素.只有在延迟期满时才能从队列中提取元素。

SynchronousQueue 是一个不存储元素的阻塞队列.每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素.它支持公平访问队列.默认情况下线程采用非公平性策略访问队列.队列本身并不存储任何元素,非常适合传递性场景.SynchronousQueue 的吞吐量高于 LinkedBlockingQueue 和 ArrayBlockingQueue。同步队列.Synchronous 同步的,同时进行的.

LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列.相对于其他阻塞队列, LinkedTransferQueue 多了 tryTransfer 和 transfer 方法.如果当前有消费者正在等待接收元素(消费者使用 take()方法或带时间限制的 poll()方法时),transfer 方法可以把生产者传入的元素立刻 transfer (传输)给消费者.如果没有消费者在等待接收元素,transfer 方法会将元素存放在队列的 tail 节点,并等到该元素被消费者消费了才返回.tryTransfer 方法是用来试探生产者传入的元素是否能直接传给消费者.如果没有消费者等待接收元素,则返回 false.和 transfer 方法的区别是 tryTransfer 方法无论消费者是否接收,方法立即返回,而 transfer 方法是必须等到消费者消费了才返回。

LinkedBlockingDeque 是一个由链表结构组成的双向阻塞队列.所谓双向队列指的是可以从队列的两端插入和移出元素.双向队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争.相比其他的阻塞队列, LinkedBlockingDeque 多了 addFirst 、 addLast 、 offerFirst 、 offerLast 、 peekFirst 和 peekLast 等方法,以 First 单词结尾的方法,表示插入、获取(peek)或移除双端队列的第一个元素.以 Last 单词结尾的方法,表示插入、获取或移除双端队列的最后一个元素。在初始化 LinkedBlockingDeque 时可以设置容量防止其过度膨胀.另外,双向阻塞队列可以运用在“工作窃取”模式中。

TransferQueue 相比 SynchronousQueue 用处更广、更好用,因为你可以决定是使用 BlockingQueue 的方法,还是确保一次传递完成。在队列中已有元素的情况下,调用 transfer 方法,可以确保队列中被传递元素之前的所有元素都能被处理。Doug Lea 说从功能角度来讲,LinkedTransferQueue 实际上是 ConcurrentLinkedQueue、SynchronousQueue(公平模式)和 LinkedBlockingQueue 的超集。而且 LinkedTransferQueue 更好用,因为它不仅仅综合了这几个类的功能,同时也提供了更高效的实现。

11.DelayQueue 的使用场景?

在很多场景我们需要用到延时任务,比如给客户异步转账操作超时后发通知告知用户,还有客户下单后多长时间内没支付则取消订单等等,这些都可以使用延时任务来实现。

  • 关闭空闲连接.服务器中,有很多客户端的连接,空闲一段时间之后需要关闭之。

  • 缓存.缓存中的对象,超过了空闲时间,需要从缓存中移出。

  • 任务超时处理.在网络协议滑动窗口请求应答式交互时,处理超时未响应的请求。

12.TransferQueue 的使用?

使用 TransferQueue 交替打印字符串

public class Juc_03_question_AbcAbc_06 {
  public static void main(String[] args){
    char[] aC = "ABC".toCharArray();
    char[] bC = "123".toCharArray();
    TransferQueue<Character> queue = new LinkedTransferQueue<>();
    new Thread(()-> {
      try {
        for (char c : aC){
          System.out.println(queue.take());
          queue.transfer(c);
        }
      } catch (InterruptedException e){
        e.printStackTrace();
      }
    },"t1").start();
    new Thread(()-> {
      try {
        for (char c : bC){
          queue.transfer(c);
          System.out.println(queue.take());
        }
      } catch (InterruptedException e){
        e.printStackTrace();
      }
    },"t2").start();
  }
}
13.为什么 SynchronousQueue 的吞吐量高于 LinkedBlockingQueue 和 ArrayBlockingQueue?

SynchronousQueue 无锁竞争,需要依据实际情况注意生产者线程和消费者线程的配比.

14.什么是 Fork/Join 框架,原理?

Fork/Join 框架是 Java 7 提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

使用算法:工作窃取算法。

工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行.那么,为什么需要使用工作窃取算法呢?假如我们需要做一个比较大的任务,可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应.比如 A 线程负责处理 A 队列里的任务.但是,有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理.干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行.而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

ForkJoinPool 由 ForkJoinTask 数组和 ForkJoinWorkerThread 数组组成, ForkJoinTask 数组负责将存放程序提交给 ForkJoinPool 的任务,而 ForkJoinWorkerThread 数组负责执行这些任务。

当我们调用 ForkJoinTask 的 fork 方法时,程序会调用 ForkJoinWorkerThread 的 pushTask 方法异步地执行这个任务,然后立即返回结果.代码如下。

public final ForkJoinTask<V> fork() {
  Thread t;
  if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
    ((ForkJoinWorkerThread)t).workQueue.push(this);
  else
    ForkJoinPool.common.externalPush(this);
  return this;
}

pushTask 方法把当前任务存放在 ForkJoinTask 数组队列里.然后再调用 ForkJoinPool 的 signalWork()方法唤醒或创建一个工作线程来执行任务.代码如下。

final void push(ForkJoinTask<?> task) {
  ForkJoinTask<?>[] a; ForkJoinPool p;
  int b = base, s = top, n;
  if ((a = array) != null) {    // ignore if queue removed
    int m = a.length - 1;     // fenced write for task visibility
    U.putOrderedObject(a, ((m & s) << ASHIFT) + ABASE, task);
    U.putOrderedInt(this, QTOP, s + 1);
    if ((n = s - b) <= 1) {
      if ((p = pool) != null)
        p.signalWork(p.workQueues, this);
    }
    else if (n >= m)
      growArray();
  }
}

Join 方法的主要作用是阻塞当前线程并等待获取结果.让我们一起看看 ForkJoinTask 的 join 方法的实现,代码如下。

public final V join() {
  int s;
  if ((s = doJoin() & DONE_MASK) != NORMAL)
    reportException(s);
  return getRawResult();
}

首先,它调用了 doJoin()方法,通过 doJoin()方法得到当前任务的状态来判断返回什么结果,任务状态有 4 种:已完成(NORMAL)、被取消( CANCELLED)、信号(SIGNAL)和出现异常( EXCEPTIONAL).·如果任务状态是已完成,则直接返回任务结果.·如果任务状态是被取消,则直接抛出 CancellationException.·如果任务状态是抛出异常,则直接抛出对应的异常。

再来分析一下 doJoin()方法的实现代码

private int doJoin() {
  int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
  return (s = status) < 0 ? s :
  ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
    (w = (wt = (ForkJoinWorkerThread)t).workQueue).
    tryUnpush(this) && (s = doExec()) < 0 ? s :
  wt.pool.awaitJoin(w, this, 0L) :
  externalAwaitDone();
}

在 doJoin()方法里,首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;如果没有执行完,则从任务数组里取出任务并执行.如果任务顺利执行完成,则设置任务状态为 NORMAL,如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL。

15.说说 java 中的原子操作类?

常用的有 13 个原子操作类,都是通过 cas 实现的.

Java 从 JDK1.5 开始提供了 java.util.concurrent.atomic 包(以下简称 Atomic 包),这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式.因为变量的类型有很多种,所以在 Atomic 包里一共提供了 13 个类,属于 4 种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段).Atomic 包里的类基本都是使用 Unsafe 实现的包装类。

atomic 提供了 3 个类用于原子更新基本类型:分别是 AtomicInteger 原子更新整形,AtomicLong 原子更新长整形,AtomicBoolean 原子更新 bool 值。

atomic 里提供了三个类用于原子更新数组里面的元素,分别是:AtomicIntegerArray:原子更新整形数组里的元素;AtomicLongArray:原子更新长整形数组里的元素;AtomicReferenceArray:原子更新引用数组里的元素。

原子更新基本类型的 AtomicInteger 只能更新一个变量,如果要原子更新多个变量,就需要使用原子更新引用类型提供的类了.原子引用类型 atomic 包主要提供了以下几个类:AtomicReference:原子更新引用类型;AtomicReferenceFieldUpdater:原子更新引用类型里的字段;AtomicMarkableReference:原子更新带有标记位的引用类型.可以原子更新一个布尔类型的标记位和引用类型.构造方法是 AtomicMarkableReference(V initialRef, boolean initialMark)

如果需要原子更新某个对象的某个字段,就需要使用原子更新属性的相关类,atomic 中提供了一下几个类用于原子更新属性:AtomicIntegerFieldUpdater:原子更新整形属性的更新器;AtomicLongFieldUpdater:原子更新长整形的更新器;AtomicStampedReference:原子更新带有版本号的引用类型.该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。AtomicMarkableReference 也可以解决 ABA 问题.

16.说说对 CountDownLatch 的理解?

CountDownLatch 允许一个线程或者多个线程等待其他线程完成操作.
在 JDK 1.5 之后的并发包中提供的 CountDownLatch 也可以实现 join 的功能,并且比 join 的功能更多.也是基于 AQS 实现的.CountDownLatch 的构造函数接收一个 int 类型的参数作为计数器,如果你想等待 N 个点完成,这里就传入 N.当我们调用 CountDownLatch 的 countDown 方法时, N 就会减 1,CountDownLatch 的 await 方法会阻塞当前线程,直到 N 变成零.由于 countDown 方法可以用在任何地方,所以这里说的 N 个点,可以是 N 个线程,也可以是 1 个线程里的 N 个执行步骤.用在多个线程时,只需要把这个 CountDownLatch 的引用传递到线程里即可。内部计数器的值不可以被修改也不可以被重新初始化.

latch.await();
latch.countDown();
17.同步屏障 CyclicBarrier 理解?

CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier).它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。

CyclicBarrier 默认的构造方法是 CyclicBarrier (int parties),其参数表示屏障拦截的线程数量,每个线程调用 await 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞.CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景

CyclicBarrier 依赖于 ReentrantLock 实现

barrier.await();
barrier.getNumberWaiting()
18. CyclicBarrier 和 CountDownLatch 的区别?

CountDownLatch 的计数器只能使用一次,而 CyclicBarrier 的计数器可以使用 reset()方法重置.所以 CyclicBarrier 能处理更为复杂的业务场景.例如,如果计算发生错误,可以重置计数器,并让线程重新执行一次。

CyclicBarrier 还提供其他有用的方法,比如 getNumberWaiting 方法可以获得 Cyclic-Barrier 阻塞的线程数量.isBroken()方法用来了解阻塞的线程是否被中断。

19.说说控制并发线程数的 Semaphore?

Semaphore (信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。Semaphore 可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接.假如有一个需求,要读取几万个文件的数据,因为都是 IO 密集型任务,我们可以启动几十个线程并发地读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有 10 个,这时我们必须控制只有 10 个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连接.这个时候,就可以使用 Semaphore 来做流量控制。

底层 AQS 实现,支持公平锁和非公平锁.默认为非公平锁。

场景:

  • 红绿灯只允许 100 辆车通行
  • 停车场只允许 2 辆同时进入
  • 连接池只允许特定数量进入
  • 流量控制-关键词
semaphore.acquire();//阻塞
semaphore.release();//释放
intavailablePermits():返回此信号量中当前可用的许可证数。
intgetQueueLength():返回正在等待获取许可证的线程数。
booleanhasQueuedThreads():是否有线程正在等待获取许可证。
void reducePermits(intreduction):减少reduction个许可证,是个protected方法。
CollectiongetQueuedThreads):返回所有等待获取许可证的线程集合,是个protected方法。
20.说说线程间交换数据的 Exchanger?

Exchanger (交换者)是一个用于线程间协作的工具类.Exchanger 用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据.这两个线程通过 exchange 方法交换数据,如果第一个线程先执行 exchange()方法,它会一直等待第二个线程也执行 exchange 方法,当两个线程都到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。

Exchanger 可以用于遗传算法,遗传算法里需要选出两个人作为交配对象,这时候会交换两人的数据,并使用交叉规则得出 2 个交配结果。

Exchanger 也可以用于校对工作,比如我们需要将纸制银行流水通过人工的方式录入成电子银行流水,为了避免错误,采用 AB 岗两人进行录入,录入到 Excel 之后,系统需要加载这两个 Excel,并对两个 Excel 数据进行校对,看看是否录入一致.如果两个线程有一个没有执行 exchange()方法,则会一直等待,如果担心有特殊情况发生,避免一直等待,可以使用 exchange (V x , longtimeout,TimeUnit unit)设置最大等待时长。

21.CopyOnWriteArrayList 的特点和原理?

它相当于线程安全的 ArrayList。和 ArrayList 一样,它是个可变数组;但是和 ArrayList 不同的时,它具有以下特性:

  • 它最适合于具有以下特征的应用程序:List 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。
  • 它是线程安全的。
  • 因为通常需要复制整个基础数组,所以可变操作(add()、set() 和 remove() 等等)的开销很大。
  • 迭代器支持 hasNext(), next()等不可变操作,但不支持可变 remove()等操作。
  • 使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。

原理:

  • CopyOnWriteArrayList 实现了 List 接口,因此它是一个队列。
  • CopyOnWriteArrayList 包含了成员 lock。每一个 CopyOnWriteArrayList 都和一个监视器锁 lock 绑定,通过 lock,实现了对 CopyOnWriteArrayList 的互斥访问。
  • CopyOnWriteArrayList 包含了成员 array 数组,这说明 CopyOnWriteArrayList 本质上通过数组实现的。
  • CopyOnWriteArrayList 的“动态数组”机制 – 它内部有个“volatile 数组”(array)来保持数据。在“添加/修改/删除”数据时,都会新建一个数组,并将更新后的数据拷贝到新建的数组中,最后再将该数组赋值给“volatile 数组”。这就是它叫做 CopyOnWriteArrayList 的原因!CopyOnWriteArrayList 就是通过这种方式实现的动态数组;不过正由于它在“添加/修改/删除”数据时,都会新建数组,所以涉及到修改数据的操作,CopyOnWriteArrayList 效率很低;但是单单只是进行遍历查找的话,效率比较高。
  • CopyOnWriteArrayList 的“线程安全”机制 – 是通过 volatile 和监视器锁 Synchrnoized 来实现的。
  • CopyOnWriteArrayList 是通过“volatile 数组”来保存数据的。一个线程读取 volatile 数组时,总能看到其它线程对该 volatile 变量最后的写入;就这样,通过 volatile 提供了“读取到的数据总是最新的”这个机制的 保证。
  • CopyOnWriteArrayList 通过监视器锁 Synchrnoized 来保护数据。在“添加/修改/删除”数据时,会先“获取监视器锁”,再修改完毕之后,先将数据更新到“volatile 数组”中,然后再“释放互斥锁”;这样,就达到了保护数据的目的。

五.线程池

1.线程池的实现原理?

image-20220418161521802

当提交一个新任务到线程池时,线程池的处理流程如下。

  1. 线程池判断核心线程池里的线程是否都在执行任务.如果不是,则创建一个新的工作线程来执行任务.如果核心线程池里的线程都在执行任务,则进入下个流程。
  2. 线程池判断工作队列是否已经满.如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
  3. 线程池判断线程池的线程是否都处于工作状态.如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。

image-20220611003529383

线程池创建线程时,会将线程封装成工作线程 Worker , Worker 在执行完任务后,还会循环获取工作队列里的任务来执行.我们可以从 Worker 类的 run()方法里看到这点。

ThreadPoolExecutor 执行 execute 方法分下面 4 种情况。

  1. 如果当前运行的线程少于 corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁).
  2. 如果运行的线程等于或多于 corePoolSize,则将任务加入 BlockingQueue。
  3. 如果无法将任务加入 BlockingQueue (队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁).
  4. 如果创建新线程将使当前运行的线程超出 maximumPoolSize,任务将被拒绝,并调用 RejectedExecutionHandler.rejectedExecution()方法。

ThreadPoolExecutor 采取上述步骤的总体设计思路,是为了在执行 execute()方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈).在 ThreadPoolExecutor 完成预热之后(当前运行的线程数大于等于 corePoolSize),几乎所有的 execute()方法调用都是执行步骤 2,而步骤 2 不需要获取全局锁。

上述线程池的运行过程在 addWorker()方法中执行.

2.创建线程池的重要参数?
  1. 核心线程数
  2. 最大线程数
  3. 生存时间
  4. 时间单位
  5. 任务队列
    • ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
    • LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按 FIFO 排序元素,吞吐量通常要高于 ArrayBlockingQueue.静态工厂方法 Executors.newFixedThreadPool()使用了这个队列。
    • SynchronousQueue:一个不存储元素的阻塞队列.每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于 Linked-BlockingQueue,静态工厂方法 Executors.newCachedThreadPool 使用了这个队列。
    • PriorityBlockingQueue:一个具有优先级的无限阻塞队列。
  6. 线程工厂:可以自定义线程的名字,方便区分
  7. 拒绝策略
    • AbortPolicy:直接抛出异常。
    • CallerRunsPolicy:只用调用者所在线程来运行任务。
    • DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
    • DiscardPolicy:不处理,丢弃掉。
  • corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建.如果调用了线程池的 prestartAllCoreThreads()方法,线程池会提前创建并启动所有基本线程。默认情况下,核心线程不会退出。可将 allowCoreThreadTimeout 设置为 true,让核心线程也退出。
  • runnableTaskQueue (任务队列):用于保存等待执行的任务的阻塞队列.
  • maximumPoolSize (线程池最大数量):线程池允许创建的最大线程数.如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务.值得注意的是,如果使用了无界的任务队列这个参数就没什么效果。
  • RejectedExecutionHandler (饱和策略):当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务.这个策略默认情况下是 AbortPolicy,表示无法处理新任务时抛出异常.在 JDK 1.5 中 Java 线程池框架提供了以下 4 种策略。
  • keepAliveTime (线程活动保持时间):线程池的工作线程空闲后,保持存活的时间.所以,如果任务很多,并且每个任务执行的时间比较短,可以调大时间,提高线程的利用率,主要是针对于临时工,不是核心线程。
  • TimeUnit (线程活动保持时间的单位):可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)、毫秒(MILLISECONDS)、微秒(MICROSECONDS,千分之一毫秒)和纳秒(NANOSECONDS,千分之一微秒).
3.谈谈 PriorityQueue 理解?

PriorityQueue 是优先级队列,通过自然排序或者用 java 的比较器实现自定义排序,无界队列,但是可以在创建时指定大小,不允许有空值,默认是最小堆,当排序相同时,随机返回一个,PriorityQueue 是非线程安全的,PriorityBlockingQueue 是线程安全的,用于多线程环境.PriorityBlockingQueue 实现原理是使用了可重入锁 private final ReentrantLock lock;

PriorityQueue 通过二叉小顶堆实现,任意一个非叶子节点的权值,都不大于其左右子节点的权值

大根堆也叫大顶堆

小根堆也叫小顶堆

top 问题时,求最小 k 个数用大根堆,因为大根堆根节点是最大的值,保存的都是小值

top 问题时,求最大 k 个数用小根堆,因为小根堆根节点是最小的值,保存的都是大值

方法作用失败处理方式
add()插入元素抛出异常
offer()插入元素返回 false
element()获取队首元素不删除抛出异常
peek()获取队首元素不删除null
remove()取出队首元素删除抛出异常
poll()取出队首元素删除null

经典方法源码:k指定的位置开始,将x逐层与当前点的parent进行比较并交换,直到满足x >= queue[parent]为止

//siftUp()
private void siftUp(int k, E x){
  while (k > 0){
    int parent = (k -1)>>> 1;//parentNo = (nodeNo-1)/2
    Object e = queue[parent];
    if (comparator.compare(x,(E) e)>= 0)//调用比较器的比较方法
      break;
    queue[k]= e;
    k = parent;
  }
  queue[k]= x;
}

该方法的作用是k指定的位置开始,将x逐层向下与当前点的左右孩子中较小的那个交换,直到x小于或等于左右孩子中的任何一个为止

//siftDown()
private void siftDown(int k, E x){
  int half = size >>> 1;
  while (k < half){
    //首先找到左右孩子中较小的那个,记录到c里,并用child记录其下标
    int child = (k << 1)+1;//leftNo = parentNo*2+1
    Object c = queue[child];
    int right = child +1;
    if (right < size &&
        comparator.compare((E) c,(E) queue[right])> 0)
      c = queue[child = right];
    if (comparator.compare(x,(E) c)<= 0)
      break;
    queue[k]= c;//然后用c取代原来的值
    k = child;
  }
  queue[k]= x;
}
4.execute 和 submit 的区别?

线程池的 execute 方法和 submit 方法的区别
可以使用两个方法向线程池提交任务,分别为 execute()和 submit()方法。
execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功.通过以下代码可知 execute()方法输入的任务是一个 Runnable 类的实例。

public interface Executor {
  void execute(Runnable command);
}

public interface ExecutorService extends Executor {
  Future<?> submit(Runnable task);

   <T> Future<T> submit(Callable<T> task);
}

submit()方法用于提交需要返回值的任务.线程池会返回一个 future 类型的对象,通过这个 future 对象可以判断任务是否执行成功,并且可以通过 future 的 get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get (long timeout , TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

5.shutDown 和 shutDownNow 的区别?

线程池 shutDown 和 shutDownNow 方法的区别
线程池可以通过调用线程池的 shutdown 或 shutdownNow 方法来关闭线程池。
它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。
但是它们存在一定的区别,shutdownNow 首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而 shutdown 只是将线程池的状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行任务的线程。
只要调用了这两个关闭方法中的任意一个,isShutdown 方法就会返回 true.当所有的任务都已关闭后,才表示线程池关闭成功,这时调用 isTerminaed 方法会返回 true.至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用 shutdown 方法来关闭线程池,如果任务不一定要执行完,则可以调用 shutdownNow 方法。

6.线程池监控?
  • taskCount:线程池需要执行的任务数量。
  • completedTaskCount:线程池在运行过程中已完成的任务数量小于或等于 taskCount。
  • largestPoolSize:线程池里曾经创建过的最大线程数量。通过这个数据可以知道线程池是否曾经满过。如该数值等于线程池的最大大小,则表示线程池曾经满过。
  • getPoolSize:线程池的线程数量。如果线程池不销毁的话,线程池里的线程不会自动销毁,所以这个大小只增不减。
  • getActiveCount:获取活动的线程数。
7.Executor 框架的结构?

Executor 框架主要由 3 大部分组成如下。

  • 任务。包括被执行任务需要实现的接口:Runnable 接口或 Callable 接口。

    • Runnable 不会有返回结果
    • Callable 有返回结果
  • 任务的执行。包括任务执行机制的核心接口 Executor,以及继承自 Executor 的 ExecutorService 接口。Executor 框架有两个关键类实现了 ExecutorService 接口(ThreadPoolExecutor 和 ScheduledThreadPoolExecutor)

  • 异步计算的结果。包括接口 Future 和实现 Future 接口的 FutureTask 类。

核心类和接口:

  • Executor 是一个接口,它是 Executor 框架的基础,它将任务的提交与任务的执行分离开来。
  • ThreadPoolExecutor 是线程池的核心实现类,用来执行被提交的任务。
  • ScheduledThreadPoolExecutor 是一个实现类,可以在给定的延迟后运行命令,或者定期执行命令。
    ScheduledThreadPoolExecutor 比 Timer 更灵活,功能更强大。
  • Future 接口和实现 Future 接口的 FutureTask 类,代表异步计算的结果。
  • Runnable 接口和 Callable 接口的实现类,都可以被 ThreadPoolExecutor 或 Scheduled-ThreadPoolExecutor 执行。

image-20220611012542009

8.说说四种线程池?
  • 特点
  • 用到的队列
ThreadPoolExecutor

通常使用工厂类 Executors 来创建。 Executors 可以创建 3 种类型的 ThreadPoolExecutor:

  • SingleThreadExecutor
    • 适用于保证顺序执行各个任务.
  • FixedThreadPool
    • 适用于需要限制当前线程数量的场景.比如负载比较重的服务器.
  • CachedThreadPool
    • 大小无界的线程池.适用于大量短期任务.或者负载比较轻的服务器.
FixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
  return new ThreadPoolExecutor(nThreads, nThreads,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>());
}
  • 核心线程数和最大线程数都是用户自己设置的 size
  • 多余的线程会被立即终止,等待时间为 0
  • FixedThreadPool 使用无界队列 LinkedBloc kingQueue 作为线程池的工作队列(队列的容量为 IntegerMAX VA ALUE)。
    • 线程池中的线程数不会超过核心线程数
    • 最大线程数是个无效参数
    • 保活时间是个无效参数
    • 不会拒绝任务
SingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
  return new FinalizableDelegatedExecutorService
    (new ThreadPoolExecutor(1, 1,
                            0L, TimeUnit.MILLISECONDS,
                            new LinkedBlockingQueue<Runnable>()));
}
  • 核心线程数和最大线程数都是默认的 1
  • 多余的线程会被立即终止,等待时间为 0
  • FixedThreadPool 使用无界队列 LinkedBloc kingQueue 作为线程池的工作队列(队列的容量为 IntegerMAX VA ALUE)。
CachedThreadPool
public static ExecutorService newCachedThreadPool() {
  return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                60L, TimeUnit.SECONDS,
                                new SynchronousQueue<Runnable>());
}
  • 核心线程数为 0
  • 最大线程数无界
  • 保活时间是 60S,等待新任务的最长时间是 60s,超过 60s 将被终止
  • CachedThreadPool 使用没有容量的 SynchronousQueue 作为线程池的工作队列,但 CachedThreadPool 的 maximumPool 是无界的。这意味着,如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新线程。
  • 极端情况下,CachedThreadPool 会因为创建过多线程而耗尽 CPU 和内存资源。
ScheduledThreadPoolExecuton

ScheduledThreadPoolExecutor 通常使用工厂类 Executors 来创建。Executors 可以创建 2 种类型的 ScheduledThreadPoolExecutor,如下。

  • ScheduledThreadPoolExecutor。包含若干个线程的 ScheduledThreadPoolExecutor

  • SingleThreadScheduledExecutor。只包含一个线程的 ScheduledThreadPoolExecutor。

ScheduledFutureTask 主要包含 3 个成员变量,如下。

  • long 型成员变量 time,表示这个任务将要被执行的具体时间。

  • long 型成员变量 sequenceNumber,表示这个任务被添加到 ScheduledThreadPoolExecutor 中的序号。

  • long 型成员变量 period,表示任务执行的间隔周期。

ScheduledThreadPoolExecutor
public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }
  • 使用 DelayedWorkQueue 作为队列

  • DelayQueue 封装了一个 PriorityQueue,这个 PriorityQueue 会对队列中的 Scheduled-FutureTask 进行排序。排序时,time 小的排在前面(时间早的任务将被先执行)。如果两个 ScheduledFutureTask 的 time 相同,就比较 sequenceNumber,sequenceNumber 小的排在前面(也就是说,如果两个任务的执行时间相同,那么先提交的任务将被先执行)。

  • 核心线程数为指定值

  • 最大线程数为无界值

  • 保活时间为 0

    image-20220611015903687

SingleThreadScheduledExecutor
public static ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory threadFactory) {
  return new DelegatedScheduledExecutorService
    (new ScheduledThreadPoolExecutor(1, threadFactory));
}

public ScheduledThreadPoolExecutor(int corePoolSize,
                                   ThreadFactory threadFactory) {
  super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
        new DelayedWorkQueue(), threadFactory);
}
  • 核心线程数是 1
  • 最大线程数无界值
  • 保活时间为 0
  • 使用 DelayedWorkQueue 队列
  • 指定线程工厂,自定义线程名字前缀
9.为何不推荐使用 Executors 创建线程?

推荐使用 ThreadPoolExecutor 方式在阿里的 Java 开发手册时有一条是不推荐使用 Executors 去创建,而是推荐去使用 ThreadPoolExecutor 来创建线程池。
这样做的目的主要原因是:使用 Executors 创建线程池不会传入核心参数,而是采用的默认值,这样的话我们往往会忽略掉里面参数的含义,如果业务场景要求比较苛刻的话,存在资源耗尽的风险;另外采 ThreadPoolExecutor 的方式可以让我们更加清楚地了解线程池的运行规则,不管是面试还是对技术成长都有莫大的好处。

10.说一下 CTL?

ctl 变量是整个线程池的核心控制状态,它是一个 AtomicInteger 类型的原子对象,它记录了线程池中生效线程数和线程池的运行状态。

  • workerCount,生效的线程数,基本上可以理解为存活的线程数。
  • runState,线程池运行状态。

ctl 总共 32 位,其中低 29 位代表 workerCount,所以最大线程数为 229-1。高 3 位代表 runState。

runState 有 5 个值:

  • RUNNING: 对应的高 3 位值是 111。接收新任务处理队列任务。
  • SHUTDOWN:对应的高 3 位值是 000。不接收新任务,但处理队列任务。
  • STOP:对应的高 3 位值是 001。不接收新任务,也不处理队列任务,并且中断所有处理中的任务。
  • TIDYING: 对应的高 3 位值是 010。所有任务都被终结,有效线程为 0,并触发 terminated()方法。
  • TERMINATED :对应的高 3 位值是 011。当 terminated()方法执行结束。

状态转换过程:

  • 当调用了 shutdown(),状态会从 RUNNING 变成 SHUTDOWN,不再接收新任务,此时会处理完队列里面的任务。
  • 如果调用的是 shutdownNow(),状态会直接变成 STOP。
  • 当线程或者队列都是空的时候,状态就会变成 TIDYING。
  • 当 terminated()执行完的时候,就会变成 TERMINATED。

六.实际应用

1.详细说下单例模式的实现?

首先看一下类的初始化条件:

  • T 是一个类,而且一个 T 类型的实例被创建;
  • T 是一个类,且 T 中声明的一个静态方法被调用;
  • T 中声明的一个静态字段被赋值;
  • T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段;
  • T 是一个顶级类(top level class,见 java 语言规范的§7.6),而且一个断言语句嵌套在 T 内部被执行。

静态内部类实现单例,在调用 getInstance()方法时,使用到了内部类的静态成员变量,所以会执行内部类的初始化,实现了静态域的延迟加载(或者延迟初始化)。

双检查锁实现了(实例域的延迟初始化,也就是对象的非静态域。)

最好的方式是使用枚举实现单例模式,因为可以避免反射供给和解决序列化之后不相等的问题,

解决反射攻击是因为 jvm 在判断类型为 ENUM 的时候回直接抛出异常,枚举的反序列化并不是通过反射实现的.
一般情况下,不建议使用懒汉方式(有两种一个是最基本的,另一个是线程安全的),建议使用饿汉方式。只有在要明确实现 lazyloading 效果时,才会使用静态内部类单例方式。如果涉及到反序列化创建对象时,可以尝试使用枚举方式实现的单例。如果有其他特殊的需求,可以考虑使用双检锁方式实现的单例。

双检查锁的使用时避免成员变量共享时出现的线程安全问题,在 dubbo 源码中我们可以看到很多场景运用的都是双检查锁,但是都是在方法内部,对于局部变量使用双检查锁,而 Java 内存模型中,我们知道 Java 栈,本地方法栈,程序计数器都是线程私有的,我们执行的方法,都是栈桢入栈操作,也就是进入到虚拟机栈(Java 栈)所以都是线程私有的不存在线程安全问题,所以使用是没有问题的。dubbo 中使用的目的不是延迟初始化,使用的目的就是我们使用了缓存的前提下,只要缓存中有的时候我们尽量去缓存中去取,只在缓存中没有的时候再去创建或者数据库中获取数据,使用双检查锁可以避免缓存的多次创建和频繁的创建或者链接数据库(在 redis 并发的场景中我们可以使用。)

2.如何设置核心线程数和最大线程数?
  • 需要进行压测

  • 并发访问量是多大

  • 不要用无界队列,且有界队列的最大值要合理

  • 充分利用 cpu

    • 一个线程处理计算型,100%
    • 50%计算型,需要 2 个线程
    • 25%计算型,需要 4 个线程
    • 多任务操作系统,对 CPU 都是分时使用的:比如 A 任务占用 10ms,然后 B 任务占用 30ms,然后空闲 60ms,再又是 A 任务占 10ms, B 任务占 30ms,空闲 60ms;如果在一段时间内都是如此,那么这段时间内的利用率为 40%,因为整个系统中只有 40%的时间是 CPU 处理数据的时间。
  • 任务的性质:CPU 密集型任务、lO 密集型任务和混合型任务。

    • CPU 密集型要设置尽量少的线程数
    • IO 密集型要设置尽量多的线程数
  • 任务的优先级:高、中和低。

    • 使用优先队列
    • 建议使用有界队列,且数量合理
  • 任务的执行时间:长、中和短。

  • 任务的依赖性:是否依赖其他系统资源,如数据库连接。

    • CPU 空闲时间越长,线程数应该设置的越大,更好的利用 CPU

CPU密集型:CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。

I/O密集型:IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。

线程池与CPU密集型的关系:

一般情况下,CPU核心数 == 最大同时执行线程数.在这种情况下(设CPU核心数为n),大量客户端会发送请求到服务器,但是服务器最多只能同时执行n个线程.设线程池工作队列长度为m,且m>>n,则此时会导致CPU频繁切换线程来执行(如果CPU使用的是FCFS,则不会频繁切换,如使用的是其他CPU调度算法,如时间片轮转法,最短时间优先,则可能会导致频繁的线程切换).所以这种情况下,无需设置过大的线程池工作队列,(工作队列长度 = CPU核心数 || CPU核心数+1) 即可.

与I/O密集型的关系:

1个线程对应1个方法栈,线程的生命周期与方法栈相同.比如某个线程的方法栈对应的入站顺序为:controller()->service()->DAO(),由于DAO长时间的I/O操作,导致该线程一直处于工作队列,但它又不占用CPU,则此时有1个CPU是处于空闲状态的.所以,这种情况下,应该加大线程池工作队列的长度(如果CPU调度算法使用的是FCFS,则无法切换),尽量不让CPU空闲下来,提高CPU利用率

3.可以重排序的情况下,如何实现线程安全的单例模式?

安全的单例模式

1.使用 volatile 禁止重排序

2.可以重排序,但重排序对其他线程不可见

image-20220418162112331

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据 Java 语言规范,在首次发生下列任意一种情况时,一个类或接口类型 T 将被立即初始化。

1)T 是一个类,而且一个 T 类型的实例被创建。

2)T 是一个类,且 T 中声明的一个静态方法被调用。

3)T 中声明的一个静态字段被赋值。

4)T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段。

5)T 是一个顶级类(Top Level Class,见 Java 语言规范的 7.6),而且一个断言语句嵌套在 T 内部被执行。

public class InstanceFactory {
 private static class InstanceHolder {
 public static Instance instance=new Instance();

 public static Instance getinstance(){
 return InstanceHolderinstance; //这里将导致InstanceHolder类被初始化
}
4.AQS 会旋转几次获取锁?

我理解的是 2 次,第一次的时候,未获取到会生成队列节点,第二次是是否为头结点,第二次可以是多次,可能出现非公平锁的饥饿状态,获取锁的过程实际上是获取 state 状态.

5.CPU 飙升 100%,排查?

发现程序 CPU 飙升 100%,内存和 I/O 利用正常,是什么原因?如何排查?

原因:死锁

排查: dump 线程数据

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fTx4JjgP-1660370613971)(https://s2.loli.net/2022/04/14/X9ym5r41FCsTbAG.png)]

6.ThreadMXBean 有哪些线程?

[6]Monitor Ctrl-Break:(跟 JVM 关系不大,他是 IDEA 通过反射的方式,开启一个随着我们运行的 jvm 进程开启与关闭的一个监听线程。)

[5]Attach Listener:(附加监听器。简单来说,他是 jdk 里边一个工具类提供的 jvm 进程之间通信的工具。cmd–java-version;jvm–jstack、jmap、dump)进程间的通信。开启我们这个线程的两个方式:1.通过 jvm 参数开启。-XX:StartAttachListener2.延迟开启:cmd–java-version–> JVM 适时开启 AttachListener 线程

[4]Signal Dispatcher:(信号分发器。我们通过 cmd 发送 jstack,传到了 jvm 进程,这时候信号分发器就要发挥作用了。)

[3]Finalizer:(JVM 垃圾回收相关的内容。此处只做简单的介绍。1.只有当开始一轮垃圾收集的时候,才会开始调用 finalize 方法。2. daemonprio=10 高优先级的守护线程。 3.jvm 在垃圾收集的时候,会将失去引用的对象封装到我们的 Finalizer 对象(Reference),放入我们的 F-queue 队列中。由 Finalizer 线程执行 Finalize 方法)

[2]Reference Handler:(引用处理的线程。强,软,弱,虚。-GC 有不同表现-JVM 深入分析)

[1]main:主线程

除了 main 线程,其他都是 daemon 线程

使用 jps 查看 java 进程

使用 jsatck pid 查看线程具体信息

7.long 和 double 的非原子性协定

Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都具有原子性,但是对于 64 位的数据类型(double、long)定义了相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次的 32 位操作来进行,即允许虚拟机可以不保证 64 位数据类型的 load、store、read 和 write 操作的原子性。

非原子性协定可能导致的问题
如果有多个线程共享一个未申明为 volatile 的 long 或 double 类型的变量,并且同时对其进行读取和修改操作,就有可能会有线程读取到"半个变量"的数值或者是一半正确一半错误的失效数据。

在实际应用中的解决
因为上述可能造成的问题,势必在对 long 和 double 类型变量操作时要加上 volatile 关键字,实际上如下:

1、64 位的 java 虚拟机不存在这个问题,可以操作 64 位的数据

2、目前商用 JVM 基本上都会将 64 位数据的操作作为原子操作实现

所以我们编写代码时一般不需要将 long 和 double 变量专门申明为 volatile

8.synchronized不同方法?

当一个线程进入一个对象的synchronized方法A之后,其它线程是否可进入此对象的synchronized方法B?

不能。其它线程只能访问该对象的非同步方法,试图进入B方法的线程就只能在等锁池(注意不是等待池)中等待对象的锁。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kwan的解忧杂货铺

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值