0%

Java并发编程基础

并发编程

  • 并发编程的三大特性(或者说是实现并发安全的三大特性)

    • 原子性
      • 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronizedLock可以保证代码片段的原子性,Atomic原子类可以保证一个变量、数组、对象、对象属性的读写的原子性
        • 注意与同步问题区分,原子性问题不是同步问题
    • 可见性
      • 当一个变量对多线程共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性,synchronized可以保证整体的可见性,也就是在同步代码块内对共享变量的修改,在退出同步代码快释放锁后立即可见(以上两者使用的其实都是内存屏障)final关键字也可以实现可见性,实现安全发布
    • 有序性
      • 代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序,并发编程应能保证无论是单线程还是多线程中的指令执行的逻辑顺序与代码编写顺序一致。volatile 关键字可以禁止指令进行重排序优化;synchronized也可以实现有序性,但是其并不是阻止同步代码块内的指令重排序(换句话说就是同步代码快内可能出现指令重排),而是保证同步代码快整体的有序
  • 并发编程的两大问题

    • 线程间如何通信,即数据信息交换的方式

    • 线程间如何同步,即如何控制不同线程执行的先后顺序

      • 总结线程同步的几种方式
        • 使用以synchronized和Lock为接口实现的锁为基础的锁同步
        • wait-notify与Condition.await-Condition.signall为代表的等待通知机制
        • 使用AQS实现的一系列同步器
        • 管道模型
    • 对应以上两大问题的两种编程模型

      image-20210517181748764

      • **Java使用的是共享内存并发模型**,但是实际上也提供了使用消息传递并发模型的机制

        • 共享内存并发模型的案例:等待通知模型wait-notify,典型案例:任务管理器

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          31
          32
          33
          34
          35
          36
          37
          38
          39
          40
          41
          42
          43
          44
          45
          46
          47
          48
          49
          50
          public class NotifyAllDemo {


          public static void main(String[] args) throws InterruptedException {

          TaskQueue taskQueue = new TaskQueue();
          new Thread(() -> {
          try {
          String task = taskQueue.getTask();
          System.out.println(task);
          } catch (InterruptedException e) {
          e.printStackTrace();
          }
          }).start();

          Thread.sleep(2000l);

          new Thread(() -> {
          // 这里也是迷惑点之一,本以为执行notifyAll 函数之后,锁还在这个线程内部,那么应该最后才打印李佳,但是不管怎么尝试都是最先打印出李佳
          // 这是因为自己忽略了synchronized的机制,在这个代码块(同步代码块或者同步函数的最后,实际上就释放掉了锁)也就是说addTask函数执行完毕后实际上就释放掉了锁,而sleep函数执行
          // 已经是另一个线程已经打印李佳之后的事了,再去考虑sleep函数只是释放CPU资源而不释放资源锁,已经没有意义了
          taskQueue.addTask("李佳");

          try {
          Thread.sleep(4000l);
          } catch (InterruptedException e) {
          e.printStackTrace();
          }
          System.out.println("添加任务完毕");
          }).start();
          }
          }

          class TaskQueue {
          Queue<String> taskQueue= new LinkedList<>();

          synchronized void addTask (String task) {
          taskQueue.add(task);
          this.notifyAll();
          }

          synchronized String getTask () throws InterruptedException {
          // 这里使用while与使用if是不一样的,如果使用if的话,很有可能,当前线程在被激活并拿到锁之后任务管理器中已经没有任务了,这样直接跳出if代码块去执行return语句就会报错,事实上,不能保证线程是第一个获得锁的,第一个获得锁的才能取得任务
          // 后续的线程可能都拿不到,比方说只有一个任务,所以wait返回后,要先使用while进行判断再做打算。
          while(taskQueue.isEmpty()) {
          this.wait();
          }
          return taskQueue.remove();
          }
          }
          • 很多线程挂起的操作都是结合while或者for循环的,因为被挂起的线程并不清楚其被唤醒之后的状态是怎样的,因此要在循环中判断,并且在一定条件下,跳出循环或者重新进入阻塞
        • 消息传递并发模型的案例:管道模型,与IO流类似,就是一个线程向另外一个线程发送字节流或者是字符流,JDK提供了PipedWriterPipedReaderPipedOutputStreamPipedInputStream。其中,前面两个是基于字符的,后面两个是基于字节流的

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
          20
          21
          22
          23
          24
          25
          26
          27
          28
          29
          30
          31
          32
          33
          34
          35
          36
          37
          38
          39
          40
          41
          42
          43
          44
          45
          46
          47
          48
          49
          50
          51
          52
          53
          54
          55
          56
          57
          58
          59
          60
          61
          62
          63
          64
          public class Pipe {

          static class ReaderThread implements Runnable {

          private PipedReader reader;

          public ReaderThread(PipedReader reader) {
          this.reader = reader;
          }

          @Override
          public void run() {
          System.out.println("This is Reader");
          int receive = 0;
          try {
          // 阻塞等待消息发送
          while((receive = reader.read()) != -1) {
          System.out.print((char) receive);
          }
          } catch (IOException e) {
          e.printStackTrace();
          }
          }
          }

          static class WriteThread implements Runnable {

          private PipedWriter writer;

          public WriteThread(PipedWriter writer) {
          this.writer = writer;
          }

          @Override
          public void run() {
          System.out.println("This is Writer");

          try {
          writer.write("test");
          } catch (IOException e) {
          e.printStackTrace();
          } finally {
          try {
          writer.close();
          } catch (IOException e) {
          e.printStackTrace();
          }
          }
          }
          }

          public static void main(String[] args) throws InterruptedException, IOException {

          PipedWriter writer = new PipedWriter();
          PipedReader reader = new PipedReader();
          // 注意这里必须执行连接操作
          writer.connect(reader);

          // 一定是Reade先阻塞等待消息发送
          new Thread(new ReaderThread(reader)).start();
          Thread.sleep(1000l);
          new Thread(new WriteThread(writer)).start();
          }
          }
  • 为什么使用多线程

    • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销,同时也可以提高多核CPU或者多CPU的利用率,除此之外,线程由于共享同一个进程的资源,因此线程间通信要比进程间通信要方便的多
      • 线程比进程的粒度更小
    • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

对于进程与线程的理解

  • 在Linux中,进程与线程没有什么区别
    • 在Linux中进程与线程的结构是一样的,只有 Linux 系统将线程看做共享数据的进程,不对其做特殊看待,线程就是轻量级的线程
      • 多线程相对于多进程的优点
        • 对于大部分操作系统来说,创建进程要比创建线程的开销大在各个线程切换比在各个进程切换消耗的资源的多
        • 线程与父进程共享部分内存,而子进程与父进程不共享内存,所以多线程之间的通信要比多进程之间的通信要简单的多
      • 多线程的缺点
        • 多进程的稳定性高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃可能会直接导致整个进程崩溃
        • 不用考虑共享资源的加锁等共享资源的同步问题,因为进程之间根本没有共享资源
          • 多进程的并发模型应该是消息传递并发模型
    • 进程是操作系统进行资源分配的基本单位,所以进程内部的线程都是共享所属进程的内存空间和资源的,而线程是操作系统进行调度的基本单位(即CPU分配时间片的基本单位)
  • JVM进程中的几个必备线程
    • main 线程,程序入口
    • Reference Handler 对于不可达对象的reference进行处理的线程、最高优先级的守护线程
    • Finalizer 调用对象 finalize 方法的线程 优先级很低的守护线程(最高优先级-2)
      • 以上两个线程的理解可以参考Unsafe类文章中的Reference 类的介绍
    • Signal Dispatcher 分发处理给 JVM 信号的线程
    • Attach Listener 添加事件
    • GC线程

线程的生命周期与状态切换

image-20210601155205747

  • 注意上图中的join方法是Thread类的方法

  • 所有使线程进入waiting状态以及timed_waiting状态的方法都可以在外部对其进行中断时抛出中断异常,进而将其唤醒

  • 对于Runnable状态的理解

    操作系统隐藏 Java 虚拟机(JVM)中的 READY 和 RUNNING 状态,在jvm层面只能看到 RUNNABLE 状态,所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。为什么jvm要把操作系统的Ready和Runing两个状态进行合并呢,因为JVM的线程状态是服务于监控的,但是在当下的时分多任务操作系统中通常会存在时间片这个概念,通过时间片的分配进行抢占式的调度,这也是单核CPU能够执行所谓并发任务的原因,其实就是时间片之间切换的非常快,让你以为是在并行执行任务,当前任务(线程或进程)在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换,拿到时间片就是Running 丢掉就是ready,这也是所谓的上下文切换的过程。但是时间片是毫秒级别的,如此快速的状态切换,对于jvm来说是没有必要区分的,现今主流的 JVM 实现都把 Java 线程一一映射到操作系统底层的线程上,把调度委托给了操作系统,我们在虚拟机层面看到的状态实质是对底层状态的映射及包装。JVM 本身没有做什么实质的调度,把底层的 ready 及 running 状态映射上来也没多大意义,因此,统一成为runnable 状态是不错的选择。

    上下文切换通常是计算密集型的

    上下文是指某一时间点 CPU 寄存器和程序计数器的内容

    Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少

  • 区分Object(对象锁)的方法与Thread的方法以及LockSupport的方法

    • 常用的LockSupport类方法(在AQS与ConcurrentHashMap、FutureTask、SynchronousQueue中使用)(不涉及锁资源的问题

      • public static void unpark(Thread thread)
        • 恢复被阻塞的线程
        • notify等方法比较,最大的差异在于,一旦执行unpark被唤醒后会立即执行后续的被阻塞的代码,而notify唤醒对应的线程后,需要重新竞争锁,才能执行后续的阻塞的代码
        • 如果在park()之前执行了unpark()会怎样?线程不会被阻塞,直接跳过后续的park(),继续执行后续内容
      • public static void park()
        • 补充:Condition.await方法执行后也完全释放了锁(Lock锁,本质上是更改AQS锁状态的值),线程的阻塞调用的是LockSupportpark方法
        • LockSupport.park方法不会导致锁资源的释放,其只负责线程的阻塞
        • 因为park方法的调用不会导致锁资源的释放,所以可以任何代码位置调用此方法,而与之类似的wait方法必须在同步代码块中执行
        • 执行park方法以至于处于waiting状态的对象,除了使用unpark方法唤醒之外,还会因为外部线程施加的中断而唤醒,但是与其他的方法不同,其不会抛出InterruptedException异常
    • 在并发编程中用到的Object类(对象锁)的方法(wait-notify)有

      • public final native void notify()
        • 唤醒一个等待当前对象的监视器(锁)的线程,如果有多个正在等待的线程的话会随机选一个
        • 当前持有锁的线程内执行完此方法后,并不会主动释放自己的锁,而只是继续执行自己接下来的任务,也就是说被唤醒的线程只是从waiting状态转换到了blocked状态(与其他正常线程共同竞争锁)—–上边的图也不是严格正确的
        • 当前线程要执行次方法的前提是已经获得了当前对象的监视器,因此应该在同步代码段(临界区)内调用此方法
        • 如果在wait()之前执行了notify()会怎样?抛出IllegalMonitorStateException异常
      • public final native void notifyAll()
        • 唤醒全部的等待当前对象的监视器(锁)的线程
      • public final void wait() throws InterruptedException
        • 暂停线程的执行
        • Thread.sleep方法没有释放锁(不允许其他需要锁的线程去推进),而wait方法释放了锁(允许其他需要锁的线程去竞争锁)
        • notifynotifyAll方法类似,执行的前提条件是当前线程持有锁,即应在临界区代码中执行
        • 被外部线程执行中断也可以结束阻塞状态
      • public final native void wait(long timeout) throws InterruptedException;
        • 暂停线程的执行,超时或者被notify唤醒,或者被外部线程中断,都可以结束阻塞状态
      • public final void wait(long timeout, int nanos) throws InterruptedException
        • 暂停线程的执行,超时或者被notify唤醒,或者被外部线程中断,都可以结束阻塞状态
    • 在并发编程中常用的Thread类的方法有

      • public static native void yield();

        • 唯一一个游走在Runnable状态内的方法,但是之前也说过,Runnable内部的状态实际上是JVM无法控制的,所以尽管yield方法的本意是当前线程让出堆处理器的占用,等待下一次被分配时间片,但是由于调度权在OS手中,所以未必生效
        • 静态方法
          • 一般Thread类中的静态方法同时也是native方法,针对的是当前线程
      • public static native void sleep(long millis) throws InterruptedException;

        • Thread.sleep方法没有释放锁(不允许其他需要锁的线程去推进),而wait方法释放了锁(允许其他需要锁的线程去竞争锁)
          • 实际上从使用的目的来看,使用wait一般用作线程间的通信交互(一种通信机制),而sleep仅仅是当前线程暂停而已
        • 静态方法
      • public static void sleep(long millis, int nanos)

      • public static native Thread currentThread();

        • 静态方法,原生方法,获取当前线程
      • public synchronized void start()

        • 实例方法,并且还是同步方法

          • 以当前的线程实例为对象锁,需要注意的是,此方法只会执行一次,也就是一个线程实例只能调用一次此方法,start内部会检查threadStatus变量,如果该线程实例已将执行了start方法则不能再次执行,同时也不能在重复执行任务(即当前线程任务已经执行完毕了,此时再次调用线程实例的start方法)否则会报IllegalThreadStateException异常

            • 可以联想到为什么线程池内的方法可以反复利用呢?
            • 实际上也只是执行了一次start方法而已,但是不要忘了线程池内部对任务做了包装,实际上执行的是runWorker方法,在此方法内部执行完上一个任务后就要在循环中等待下一个任务了–getTask
          • start方法内部会调用start0这个原生方法,而start0方法会调用Thread实例的run方法,这里又分两个情况(即运行新线程,或者使用线程实例的两个方式

            • 构造线程时传入Runnable实例(可以使用lambda),此时直接调用lambda或者是Runnable实例中的run方法
              • 相对来说,实现Runnable接口来构造多线程任务时更合适的
                • 接口实现比子类的实现更加灵活,轻量级
                • 降低了线程与线程任务的耦合
                • 在实际使用线程池时直接提交Runnable任务或者Callable任务即可
            • 自定义Thread的子类,并复写其run方法,start0方法会直接调用此run方法
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19
            20
            21
            22
            23
            24
            25
            26
            27
            28
            29
            30
            31
            32
            33
            public synchronized void start() {
            /**
            * This method is not invoked for the main method thread or "system"
            * group threads created/set up by the VM. Any new functionality added
            * to this method in the future may have to also be added to the VM.
            *
            * A zero status value corresponds to state "NEW".
            */

            // 检查线程实例的状态,如果不是NEW状态的话,就会报错
            if (threadStatus != 0)
            throw new IllegalThreadStateException();

            /* Notify the group that this thread is about to be started
            * so that it can be added to the group's list of threads
            * and the group's unstarted count can be decremented. */
            group.add(this);

            boolean started = false;
            try {
            start0();
            started = true;
            } finally {
            try {
            if (!started) {
            group.threadStartFailed(this);
            }
            } catch (Throwable ignore) {
            /* do nothing. If start0 threw a Throwable then
            it will be passed up the call stack */
            }
            }
            }
            • Thread源码中并没有对threadStatus变量的设置,应该是JVM负责维护此变量的状态,具体的该变量的值与线程状态的对应关系可以在getState方法中看到

              1
              2
              3
              4
              5
              6
              7
              8
              9
              10
              11
              12
              13
              14
              15
              16
              17
              18
              19
              20
              21
              22
              public State getState() {
              // get current thread state
              return sun.misc.VM.toThreadState(threadStatus);
              }


              // class VM
              public static State toThreadState(int var0) {
              if ((var0 & 4) != 0) {
              return State.RUNNABLE;
              } else if ((var0 & 1024) != 0) {
              return State.BLOCKED;
              } else if ((var0 & 16) != 0) {
              return State.WAITING;
              } else if ((var0 & 32) != 0) {
              return State.TIMED_WAITING;
              } else if ((var0 & 2) != 0) {
              return State.TERMINATED;
              } else {
              return (var0 & 1) == 0 ? State.NEW : State.RUNNABLE;
              }
              }
      • public void interrupt()

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        public void interrupt() {
        if (this != Thread.currentThread())
        checkAccess();
        // 至于blockerLock以及blocker这些暂时没有看
        synchronized (blockerLock) {
        Interruptible b = blocker;
        if (b != null) {
        interrupt0(); // Just to set the interrupt flag
        b.interrupt(this);
        return;
        }
        }
        interrupt0();
        }
        • 实例方法:对特定线程实例执行中断
        • 需要注意的是仅仅是设置了特定线程的中断标志,而不是直接执行中断策略,相当于是外部通知线程要中断,但是到底如何应对这个通知,由线程自己决定(程序员编码决定),线程内部使用isInterrupted方法判断自己是否被通知中断
        • 对于其他的函数,如果函数声明中有抛出InterruptedException异常,一般就意味着在该函数的执行(阻塞)过程中如果当前线程被中断,就会抛出该异常,这也是检测当前线程是否被中断的一个方式;换一个角度想,中断也是唤醒线程的方法之一
          • 比如说wait、sleep、join等方法
      • public boolean isInterrupted()

        1
        2
        3
        public boolean isInterrupted() {
        return isInterrupted(false);
        }
        • 实例方法,判断当前线程实例是否被中断
      • public static boolean interrupted()

        1
        2
        3
        public static boolean interrupted() {
        return currentThread().isInterrupted(true);
        }
        • 静态方法:判断当前线程是否中断

        • isInterruptedinterrupted方法的区别是什么?

          • 除了一个是静态方法,一个是实例方法之外,最大的不同在于内部调用的isInterrupted()方法的参数,一个是true一个是false。对于isInterrupted来说,判断如果是中断状态,并不会清除中断标志,但是interrupted来说,判断完毕后会清除对应的中断标志,如果连续使用此函数判断,会发现,前一次为true,后一次却为false

            demo

            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19
            20
            Thread thread1 = new Thread(){
            @Override
            public void run() {

            //thread.join();
            while(!this.isInterrupted()) {
            }

            System.out.println(this.isInterrupted());
            System.out.println(Thread.interrupted());
            System.out.println(Thread.interrupted());
            System.out.println(this.isInterrupted());

            System.out.println("thread1 结束");

            }
            };
            thread1.start();

            thread1.interrupt();
          • 在AQS的acquireQueued中用到了interrupted方法

      • public final synchronized void join(long millis) throws InterruptedException

      • public final synchronized void join(long millis, int nanos) throws InterruptedException

      • public final void join() throws InterruptedException

        • Thread实例方法,使当前线程等待另一个线程执行完毕之后再继续执行,内部调用的是Object类的wait方法实现的;在线程A中对线程B调用join方法就会在执行到join方法后等待B线程结束,才会继续执行后续的A线程的内容

          1
          public final void join() throws InterruptedException {  join(0);}public final synchronized void join(long millis)  throws InterruptedException {  long base = System.currentTimeMillis();  long now = 0;  if (millis < 0) {    throw new IllegalArgumentException("timeout value is negative");  }  if (millis == 0) {    while (isAlive()) {      wait(0);    }  } else {    while (isAlive()) {      long delay = millis - now;      if (delay <= 0) {        break;      }      wait(delay);      now = System.currentTimeMillis() - base;    }  }}
          • join内部调用的是wait方法,这意味着,线程A内部调用线程B实例的join方法时,线程A需要首先获取线程B实例的对象锁,然后线程A执行wait方法后,丢掉线程B的实例对象锁,并阻塞在此处,直至线程B完成任务进入死亡状态,此时isAlive返回false,此时wait会被唤醒,其原理就是所谓的spurious wakeup,暂时没有深入理解
          • 因为join内部调用了wait方法,所以join方法加上了synchronized,这样就不用调用者手动写同步代码了;从另一方面思考,实际上让调用者写以线程实例作为锁对象的同步代码是比较诡异的,join方法的使用会丢掉锁,但是仅仅是丢掉线程实例这个锁,线程实例对应的那个线程不会涉及到抢夺自己线程实例的锁,这没有必然关系,不要混淆
        • 注意一种特殊情况:对一个线程状态是Terminated或者NEW状态的线程实例调用join方法时不会阻塞,而是立即返回join方法内部有对于线程是否存活的判断

    • 区分锁与时间片

      • 时间片属于操作系统层级的任务调度
        • 应该认为当线程状态处于Runnable状态时,就是处于正在执行的状态,而不是像等待锁一样去等待时间片
          • 一方面Runnable状态就是JVM理解的线程运行的状态
          • 另一方面时间片的调度是很快的,不像锁一样,时间片不是为了完成并发任务的同步操作,只是为了CPU自己处理并发任务罢了
      • 锁是属于JVM或者Java层级的任务调度以实现并发同步

对于Thread类的理解

  1. Thread的初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    private void init(ThreadGroup g, Runnable target, String name,
    long stackSize, AccessControlContext acc,
    boolean inheritThreadLocals) {

    // 线程名不能为空
    if (name == null) {
    throw new NullPointerException("name cannot be null");
    }

    this.name = name;

    // 新线程并未开启,因此当前线程就是这个新线程的父线程
    Thread parent = currentThread();
    SecurityManager security = System.getSecurityManager();
    if (g == null) {
    /* Determine if it's an applet or not */

    /* If there is a security manager, ask the security manager
    what to do. */
    if (security != null) {
    g = security.getThreadGroup();
    }

    /* If the security doesn't have a strong opinion of the matter
    use the parent thread group. */
    if (g == null) {
    g = parent.getThreadGroup();
    }
    }

    /* checkAccess regardless of whether or not threadgroup is
    explicitly passed in. */
    g.checkAccess();

    /*
    * Do we have the required permissions?
    */
    if (security != null) {
    if (isCCLOverridden(getClass())) {
    security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
    }
    }

    g.addUnstarted();

    this.group = g;


    // 默认的守护线程的子线程也会是守护线程
    this.daemon = parent.isDaemon();
    // 默认的子线程的优先级是父线程的优先级
    this.priority = parent.getPriority();
    if (security == null || isCCLOverridden(parent.getClass()))
    this.contextClassLoader = parent.getContextClassLoader();
    else
    this.contextClassLoader = parent.contextClassLoader;
    this.inheritedAccessControlContext =
    acc != null ? acc : AccessController.getContext();
    this.target = target;
    // 更新优先级
    setPriority(priority);
    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
    this.inheritableThreadLocals =
    ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
    /* Stash the specified stack size in case the VM cares */
    this.stackSize = stackSize;

    /* Set thread ID */
    tid = nextThreadID();
    }
    • 各个构造函数最终都是调用此私有函数完成线程的创建与初始化

    • 对于几个参数的理解

      • ThreadGroup 线程组,可以设置为null,此时线程组由security.getThreadGroup()获得;如果security.getThreadGroup()返回null或者securitynull,则直接认定新线程所属线程组是其父线程所属的线程组

        • 注意如何判定父线程的?
        • 创建当前线程实例的线程就是当线程实例对应线程的父线程,而不是执行线程实例的start方法的线程(猜测起来可能更合理,但是事实不是这样),因为父线程在init方法就确定了

        group – the thread group. If null and there is a security manager, the group is determined by SecurityManager.getThreadGroup(). If there is not a security manager or SecurityManager.getThreadGroup() returns null, the group is set to the current thread’s thread group.

      • Runnable target 顾名思义,要执行的任务

      • String name 线程的名字,如果不指定的话就是"Thread-" + nextThreadNum()

        1
        2
        3
        4
        private static int threadInitNumber;
        private static synchronized int nextThreadNum() {
        return threadInitNumber++;
        }
        • 尽量使用自己指定的线程名字吧,否则每一次计数都要加锁进行计算
        • 自定义线程名字时,多个线程的名字是可以重复的,并没有针对线程名字重复性的检查,但是最好还是不要重复吧
      • AccessControlContext acc 用于初始化一个私有变量inheritedAccessControlContext,这个变量有点神奇。它是一个私有变量,但是在Thread类里只有init方法对它进行初始化,在exit方法把它设为null。其它没有任何地方使用它。一般我们是不会使用它的,那什么时候会使用到这个变量呢?可以参考这个stackoverflow的问题:Restrict permissions to threads which execute third party software

      • boolean inheritThreadLocals 如果为true,则线程继承父线程的inheritableThreadLocals

      • long stackSize 预期的该线程需要的栈的大小,也就是线程独占的虚拟机栈的深度,这个深度直接决定了该线程执行的嵌套递归的深度,也可以设置为0,由jvm做设置

    • ThreadFactory

Runnable与Callable的区别、FutureTask类、CompletableFuture

  • CallableRunnable的差异:

    • 前者的执行方法内部可以有返回值,并且如果无法得到有效返回值还可以抛出异常,后者的执行方法中没有返回值也不能抛出异常
    • 工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。(Executors.callable(Runnable task)或 Executors.callable(Runnable task,Object resule)
  • 补充Future接口的作用

    • 可以通过isDone判断任务是否执行完

    • 通过get方法获得执行结果

      1. 注意此方法是阻塞方法,需要等待任务执行完毕后才能返回
    • 通过cancel方法可以尝试取消任务的执行

      1
      2
      3
      4
      5
      6
      7
      8
      public abstract interface Future<V> {
      public abstract boolean cancel(boolean paramBoolean);
      public abstract boolean isCancelled();
      public abstract boolean isDone();
      public abstract V get() throws InterruptedException, ExecutionException;
      public abstract V get(long paramLong, TimeUnit paramTimeUnit)
      throws InterruptedException, ExecutionException, TimeoutException;
      }
    • Future接口常常在线程池的submit方法中使用,因为该方法返回的就是Future类型

  • 线程池中使用Callable-Future凭借的是FutureTask

    • submit方法内部会将提交的任务(无论是Runnable还是Callable)都转化为FutureTask再提交给execute方法

    • submit方法的返回对象的类型也是FutureTask

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      public class FutureTask<V> implements RunnableFuture<V> {
      // ...
      }


      public interface RunnableFuture<V> extends Runnable, Future<V> {
      /**
      * Sets this Future to the result of its computation
      * unless it has been cancelled.
      */
      void run();
      }
      • FutureTask类实现的是RunnableFuture接口,而后者继承了RunnableFuture两个接口,这样的设计原因是(个人理解)
        • 从线程池的角度来看此接口将任务与返回结合,可以直接将引用提交给execute方法,然后再将引用返回即可使用到两个接口的功能
        • 前面说到了Future只是一个接口,而它里面的cancel,get,isDone等方法要自己实现起来都是非常复杂的。所以JDK提供了一个FutureTask类(接口的实现)来供我们使用
      • 在很多高并发的环境下,有可能Callable和FutureTask会创建多次。FutureTask能够在高并发环境下确保任务只执行一次
      • FutureTask在jdk1.7时依赖AQS构建,在JDK1.8后改为使用CAS+state变量的维护+WaitNode类型的链表来维护等待的线程
    • 具体的可以查看线程池讲解中的FutureTask部分

  • CompletableFuture类,提供了进一步 的异步任务的封装

    • 当然这里说的异步任务不是异步IO,但是对于异步的理解是类似的,由CallableFuture接口引入的异步任务执行框架,在使用的时候需要主线程去轮训任务的执行程度,并不是真正的异步概念,从Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法,在语法和理解上与nodejs相似
    • 此类甚至将线程池也封装了进去,只需要定义好任务,就会放入到默认线程池中去执行
      • 应该是基于ForkJoin线程池
    • 除了引入回调机制外,还引入了对于异步任务的定制,可串行也可以并行(即异步任务的有序性,类似于nodejs中的Promise)
      • 对于Java并发编程中的一般的多线程任务来说,就是同一锁对象下的任务串行,不同锁对象(或者线程私有的任务)下的任务是并行,而CompletableFuture显然更加灵活
    • 具体的使用方法可以参考廖旭峰老师的文章

线程组

  • 线程组顾名思义可以组织一批线程进行统一管理,每个Thread必然存在于一个ThreadGroup中,Thread不能独立于ThreadGroup存在
  • ThreadGroup管理着它下面的Thread,ThreadGroup是一个标准的向下引用的树状结构,这样设计的原因是防止”上级”线程被”下级”线程引用而无法有效地被GC回收———?????
    • 这里说的是线程组内的线程之间的父子关系是向下引用的,也就是子线程不持有父线程的引用
  • 父线程可以捕获到子线程的异常吗
    1. 默认情况下是不可以的,应该要子线程的异常在自己内部解决而不是委托到外部
    2. 但是可以手动的设置Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandle());
      1. 使用此静态方法设置以后,在线程遇到异常时会调用ThreadGroup中的uncaughtException方法(由JVM调用),并最终调用调用自定义的UncaughtExceptionHandler提供的方法

线程优先级

  • Java中线程优先级可以指定,范围是1~10。但是并不是所有的操作系统都支持10级优先级的划分(比如有些操作系统只支持3级划分:低,中,高),Java只是给操作系统一个优先级的参考值,线程最终在操作系统的优先级是多少还是由操作系统决定
    • 高优先级的线程将会比低优先级的线程有更高的几率得到执行,并不是确定的,因为线程调度的控制权并不在JVM手中
    • 使用方法Thread类的setPriority()实例方法来设定线程的优先级
  • 应该在线程启动(start)之前,设置好优先级(setPriority
  • 线程组可以设置最大优先级的,如果线程组内的线程设置的优先级比线程组的最大优先级高的话,线程的优先级会失效并设置为线程组的最大优先级

守护线程

  • 守护线程的优先级默认比较低,有优先级很高的守护线程比如Reference Handler ,也要优先级很低的守护线程比如Finalizer

    • Finalizer的优先级线程也不低,是最高优先级-2
  • 非守护线程结束后,JVM就会退出,非守护线程全部结束后,守护线程也会自动退出

  • 一个线程默认是非守护线程,可以通过Thread类的setDaemon(boolean on)来设置

    • 在线程启动之前设置是否是守护线程
  • 常见的守护线程的类型

对于synchronized关键字的理解

基础理解

  • synchronized 关键字可以保证并发编程中的可见性、有序性、原子性
  • 线程之间竞争,说直白点就是竞争资源,既然需要竞争,那么这个锁资源应该是独一份的,下边三种使用场景中的对象实例,Class实例,实际上都符合这种独一份的特征

三种使用场景

  1. 修饰实例方法,锁是对象实例
  2. 修饰静态方法,锁是类的Class对象实例
    1. 如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁
  3. 修饰代码块,需要在参数中指定需要的锁是哪个对象或者Class实例(当然本质上也是对象)
  4. 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!——话虽如此,但是自己并不苟同,有什么道理吗

线程安全的类

  • 对于一个类,如果被允许是多线程的正确访问的话,那么我们说这个类是线程安全的一个类如果没有特殊说明的话,那么默认是线程不安全的类

  • 线程安全的类有大致以下几种情况

    • 类的成员变量是final修饰的(**一般称之为不变类**),只能读不能改

      • 比如String类、包装类等等
    • 对成员变量的操作只有读,没有写

      • 甚至没有成员变量,或者说类中没有需要维护的状态,比如Spring开发中的controller,mapper,service等一般不会有内部状态,因此即便默认使用的是单例,也并不存在线程安全的问题
    • 恰当使用synchronized等线程同步机制的类

synchronized使用的注意事项(或者说是编译阶段的锁优化手段

  1. jvm只能保证一个锁对象是在同一时刻只能被同一个线程获取,但是如果使用了多个锁对象,就不能一并保证了,所以注意使用正确的锁对象

  2. 锁的分离(与降低锁的粒度是类似的):与1对应的是,多组代码未必需要同一个锁,合理的锁拆分,可以提高并发效率案例

    1. 不仅仅是锁拆分,也可以通过更改代码结构实现不加锁(使用局部变量,或者是尽量改造为原子语句见3

      1
      class Pair {    int first;    int last;    public void set(int first, int last) {        synchronized(this) {            this.first = first;            this.last = last;        }    }}class Pair {    int[] pair;    public void set(int first, int last) {      	// 统统使用局部变量        int[] ps = new int[] { first, last };        // 共享变量只有引用赋值,因此也是安全的        this.pair = ps;    }}
    2. 甚至包括读写锁、LinkedBlockingQueue也是这样的例子

  3. 对于本身就是原子的语句来说,不必加锁,比如以下几种情况

    1. 基本类型(long和double除外)赋值,例如:int n = 12 (这里需要注意的就是,只有简单的读取赋值才是原子的,例如int n = m这个语句就不是原子的,因为其中实际上涉及了两个步骤,一个是读取m的值,另外一个就是将这个读取的值赋予n
      1. i++这样的语句也是原子的吗?如果i是一个局部变量的话,因为实际上编译后只对应一个指令iinc n by m,那么是原子的,但是局部变量本身就是线程安全的,如果是对一个共享变量执行++操作,就不是原子的了,使用的指令也不是iinc了
    2. 引用类型赋值,例如:List<String> list = anotherList
    3. long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的
  4. 降低锁的粒度,比如从锁一个函数到锁函树内的代码块,比如ConcurrentHashMap从1.7的Segment分段锁到1.8的存储桶首节点的锁

    1. 再比如说单例模式中的加锁单例模式变为双重校验加锁的方式
  5. 减少锁持有的时间,避免大量线程的阻塞与超时

  6. 锁竞争激烈时,可以考虑禁用偏向锁禁用自旋锁

  7. 减少锁竞争,避免锁升级

最经典的使用场景—双重校验+sync锁实现单例模式

  • 这里直接附上代码,具体的解释,参考自己的设计模式的文档

    1
    public class Singleton {    // 使用volatile修饰是关键的    private static volatile Singleton instance;    //声明为 private 避免调用默认构造方法创建对象    private Singleton() {}    // 双重锁检验    public static Singleton getInstance() {        //需要使用volatile的关键就在于这里的if判断        if (instance == null) { // 第7行            synchronized (Singleton.class) {                if (instance == null) {                    // 饿汉式创建                    instance = new Singleton(); // 第10行                }            }        }        return instance;    }}
    • 主要涉及的实际上就是synchronizedvolatile在重排序方面的比较

synchronized与volatile的简单比较与双重校验单例模式的解析

  • volatile发挥的作用(可见性,有序性)

    • 可见性,当if判断时,如果有其他线程成功创建了实例,可以对其他线程可见,进而跳过if-check

    • 有序性,虽然第十行处的代码已经是线程安全的,但是还是有重排序带来的隐患(本质上的隐患是重排序带来的,但是实直接的隐患的起因实际上是synchronized前边的未受保护的if-check,分析如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      instance = new Singleton(); // 第10行

      // 可以分解为以下三个步骤
      memory=allocate();// 分配内存 相当于c的malloc
      ctorInstanc(memory) //初始化对象
      s=memory //设置s指向刚分配的地址

      // 上述三个步骤可能会被重排序为 1-3-2,也就是:
      memory=allocate();// 分配内存 相当于c的malloc
      s=memory //设置s指向刚分配的地址
      ctorInstanc(memory) //初始化对象


      // 上边的实际上是伪代码,但是从真正的字节码分析,执行流程是一样的


      SynchronizedTest test = new SynchronizedTest();

      // 先使用new指令,指定对应的class在常量池中的符号引用
      // 新的对象的引用会返回到操作数栈
      0 new #5 <stack/SynchronizedTest>
      // 复制操作数栈栈顶的元素
      3 dup
      // 使用操作数栈顶的引用执行init构造函数
      4 invokespecial #6 <stack/SynchronizedTest.<init>>
      // 还剩一个引用存储到局部变量表中
      7 astore_1
      8 return
      • 比如线程A在第10行执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候另一个线程B执行到了第7行,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!

      • s=memory这条指令是volatile变量的写,由于volatile禁止重排序所以不会在前边两条指令之前执行

        • 重排序不是指的Java语句的重排,而是具体执行指令的重排,当一个语句对应多条指令的时候,其内部的指令也有重排序的可能
      • 获得锁之后为什么还要进行if-check?,因为等待锁的过程中,前边的线程可能已经获得锁并完成了唯一实例的初始化

      • 这里可能会有以下的误解:在自己的印象中,synchronized是比volatile作用范围更大的加锁,而new对象的语句在synchronized代码块中,难道synchronized不能保证有序吗?

        • sync可以保证有序,但是不能防止出现指令重排,换句话说,sync与volatile在实现有序性的方法上是不同的,volatile通过内存屏障直接防止指令重排以实现有序性,而sync是通过互斥锁来实现有序性(在同步代码的边界也使用了内存屏障),也就是在单线程内可以执行任意的指令重排,但是其结果对于其他线程来说是一致的,在单例模式的案例中,意思就是,在单个线程执行完sync中的代码块后,其余的线程总能拿到一个实例,但是在这个单个线程执行的瞬间,内部的new关键字允许进行重排序,那么问题就出在第一个if判断处
          • 所谓有序是想要实现的效果,而防止出现指令重排是实现的方式之一
        • 第一个if判断处针对instance变量进行了判断,并且尤其重要的是该if判断不再sync代码块内,因此指令重排序会影响到其他线程执行此if判断,就会发生上述的描述的情景了

构造函数可以使用synchronized吗

  • 先说结论:构造方法不能(不是不用,是不能)使用 synchronized 关键字修饰

  • 构造方法本身就属于线程安全的,不存在同步的构造方法一说

    • 构造方法不需要同步化,因为它只可能发生在一个线程里,在构造方法返回值前没有其他线程可以使用该对象。(一个线程已经在构造方法里面了,另外一个线程也可以调用构造方法,第一个线程里面生成的对象和第二个线程里面生成的对象是不同的对象。)

    • 以上的说法针对的是构造函数创建出的对象这个结果是不受并发编程的影响的,所以构造方法不需要使用synchronized修饰,但是创建对象的过程,各个属性的值的分配就可能遇到线程安全的问题

      • 构造函数中引用类的静态变量,需要考虑加锁(构造函数内部使用synchronized代码块加锁)

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        /* output
        Thread-0到达
        Thread-1到达
        0
        0
        */
        public class Test {
        public static int id=0;
        public int c;
        public Test() {
        // 引用静态成员变量
        c=id;
        System.out.println(Thread.currentThread().getName() + "到达");
        try{
        Thread.sleep(100);
        }catch(InterruptedException e){
        e.printStackTrace();
        }
        // 更改静态成员变量
        id++;
        }
        public static void main(String [] args){
        class ThreadTst extends Thread {
        public void run(){
        Test test=new Test();
        System.out.println(test.c);
        }
        }
        new ThreadTst().start();
        new ThreadTst().start();
        }
        }


        /* output
        Thread-0到达
        0
        Thread-1到达
        1
        */
        public class Test {
        public static int id=0;
        public int c;
        public Test() {
        synchronized(Test.class) {
        c=id;
        System.out.println(Thread.currentThread().getName() + "到达");
        try{
        Thread.sleep(100);
        }catch(InterruptedException e){
        e.printStackTrace();
        }
        id++;
        }
        }
        public static void main(String [] args){
        class ThreadTst extends Thread {
        public void run(){
        Test test=new Test();
        System.out.println(test.c);
        }
        }
        new ThreadTst().start();
        new ThreadTst().start();
        }
        }
        • 在构造函数中,使用synchronized代码块可以使用Class实例做锁
        • 之所以在构造函数中使用静态成员变量会有线程安全的问题是因为静态成员变量在类加载后就已经存在,并且是类的全部实例都能访问的
          • 与之对比的是,如果仅仅在构造函数中访问非静态成员变量,就不会有任何问题,因为非静态成员变量的初始化就是在构造函数中完成的,仅与当前的构造过程相关,多线程之间不会相互影响
      • 将视角转回构造函数的返回,也就是对象实例,如果对象引用被交给了其他线程(也就是传说中的this逃逸),其他线程可能在this实例还未初始化完毕时就访问了其中的变量,这有可能产生同步问题(实际上就是安全发布问题)。这时需要显式同步this对象

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        class A {
        int i;

        public A () {
        // 在构造函数使用this做锁
        synchronized(this) {
        new Snippet(this);
        try {
        Thread.sleep(10);
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        i = 2;
        }
        }
        }

        public class Snippet extends Thread {
        A a;

        public Snippet(A a) {
        this.a = a;
        start();
        }

        public void run() {
        // 对于逃逸的this,在访问其对象的成员时,要进行加锁,保证对象已经完成了构建
        synchronized(a) {
        System.out.println(a.i);
        }
        }

        public static void main(String[] args) {
        new A();
        }
        }
      • 补充:synchronized修饰的方法在继承关系中的展示:子类可以置换掉父类的同步方法,使它同步或不同步。这就是说,子类的方法不继承其父类方法的synchronized特性。父类的方法不改变,如果明显地调用父类的同步方法,那么这个将是同步调用的

synchronized在JVM层面的原理

  • 本质上是对 对象监视器monitor的获取
    • 每个对象都有自己的对象监视器(由C++实现)
  • 通过javap执行反编译查看字节码文件

在代码块中

  • synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权

  • 在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0(synchronized是一个可重入锁) 则表示锁可以被获取,获取后将锁计数器加 1

  • 在执行 monitorexit 指令后,将锁计数器-1,锁计数器为0时表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止

    • monitorenter指令是在编译后插入到同步代码块的开始位置, 而monitorexit指令是在编译后插入到同步代码块的结束处或异常处(这意味着同步代码块中出现异常后,也会正常释放锁)
      • 具体的字节码指令参考JVM的学习文章中

    在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象,依赖于底层的操作系统的 Mutex Lock 来实现互斥,Monitor可以和Java对象实例一起被创建和销毁

    另外,wait/notify/notifyAll等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因

在方法中

  • synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
    • 本质上还是对 对象监视器monitor的获取

JDK1.6后对于synchronized的优化有哪些

  • 在 JDK1.6版本之前,synchronized 属于 重量级锁,效率低下

    所谓重量级锁指的就是监视器锁(monitor),是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高

  • JDK1.6之后在 JVM 层面对 synchronized 进行了较大优化,现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以轻易升级但是很难降级,这种策略是为了提高获得锁和释放锁的效率

    锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级

    无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,无锁其实就是乐观锁,后面有详细的介绍

    • synchronized的优化实际上就是锁的种类的细分以及锁的范围的扩张(锁粗化)和缩小(锁消除)

      • 锁的细分:个人认为这与引用类型的划分(甚至垃圾收集算法中的分区算法)很相似,锁的种类细分后(包括Lock中分为读锁与写锁等等),可以在特定的环境下使用更轻便的锁,提升了效率,而引用类型的划分,使得JVM垃圾回收更加游刃有余,有利于垃圾回收,同时也使得程序员可以参与管理对象的生命周期

      • 锁的范围的扩张

        • 锁粗化

          • 扩大加锁范围,避免反复的加锁和解锁

            如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

            如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部(由多次加锁编程只加锁一次)

            通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短

          • 锁粗化的前提

            • 就是因为锁的范围扩大而被加入到临界区的不需要同步的代码能够很快速地完成,如果不需要同步的代码需要花很长时间,就会导致同步块的执行需要花费很长的时间,这样做也就不合理了
        • 锁消除

          • 锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除
          • 要开启逃逸分析判断,-server -XX:+DoEscapeAnalysis-XX:+EliminateLocks
          • 一个案例就是在函数中使用StringBuffer类型的局部变量,未逃逸,此时不用加锁
  • 目前,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字,不用再质疑synchronized的使用会带来性能损失了~

  • 补充临界区的概念

    所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行

从对象头讲起

  • 简单的说,Java中的锁是基于对象的,所以对象数据中一定包含着对于锁的描述—–Java对象头中存储着该对象作为锁的锁类型

  • 在HotSpot虚拟机中, 对象在内存中的布局分为三块区域:

    • 对象头
    • 实例数据
    • 对齐填充
  • 对象头的结构

    • 对象头的大小是2或3个字宽,如果是非数组对象,则分别是Mark Word类型指针;如果是数组类型则再加上一个数组的长度

      image-20210501180347284

      • 锁信息就在Mark Word
        • Mark Word用于存储对象自身的运行时数据, 如HashCode, GC分代年龄, GC标记,锁状态标志, 线程持有的锁, 偏向线程ID等等
      • 对象类型指针在JVM层面很有用,多态中,对象的实际类型应该就是通过存储在对象头中的对象类型指针获得的
        • 指针指向改对象所属类在方法区存储的元信息的位置,之后就可以通过查方法区的方法表的偏移量(直接引用)来获得存储在方法区的类的方法,进而执行

      在32位处理器中,一个字宽是32位;在64位虚拟机中,一个字宽是64位

    • 多线程下synchronized的加锁就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作

      • CAS不是于synchronized相互独立,相互隔绝的另一种锁机制,CAS只是一种原子操作的机制,可以使用CAS来实现特定类型的锁功能,所以不要将两者相提并论,或者相互对立
  • 对象头中的Mark Word(记录锁信息)

    image-20210507220701292

    • 上表中并未涉及到对象的hashCode的信息,查看下图进行对应补充,下图以64位字宽为例子

      image-20210525214708667

    • 当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record(锁记录)的指针;当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针

无锁

  • 无锁就是乐观锁,总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性
  • 由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁

偏向锁(Java15中放弃偏向锁)

  • 根据经验表明,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁,所谓偏向就是偏向于第一个获得锁的线程,一旦获得锁后,在没有特殊情况时是不会主动释放锁的
  • 偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能
    • 轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可
偏向锁的实现原理

image-20210510202504128

  • 偏向模式即为偏向锁,抢占模式即为从偏向锁升级到了轻量级锁(多个线程同时抢占锁

  • 需要理解这样的一个场景,就是两个线程轮流着来获取某偏向锁,如果又恰好不产生竞争,那么就会不断的进行CAS互相替换,这是合理的,毕竟这样的场景并发也不高,但是如果多个线程同时想要获得此锁,那么就要考虑升级锁状态了

  • 偏向锁的获取与释放、升级注意理清除偏向锁的释放与升级

    当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新通过CAS竞争偏向新的线程

    image-20210508140355025

    • 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁
      • 所谓的竞争出现则释放偏向锁的意思是一旦出现竞争,肯定会出现锁的释放,不管是原持有线程已经死亡,还是未死亡,都会最终执行偏向锁的撤销——所以当竞争激烈时不建议使用偏向锁
    • 偏向锁撤销并升级为轻量级锁的过程是比较复杂的
      • 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
      • 遍历线程栈(线程获得偏向锁时,不仅仅在MarkWord中设置偏向ID,也在栈帧中记录了偏向的线程ID),如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态
      • 唤醒被停止的线程,将当前锁升级成轻量级锁。
    • 偏向锁适用于竞争小的场景,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭-XX:UseBiasedLocking=false;偏向锁在Java6及更高版本中是默认启用的, 但是它在程序启动几秒钟后才激活. 可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟(建议开启启动延迟,因为刚启动时,线程竞争会比较激烈)
    • Java15中正式放弃此偏向锁,大量的使用CAS等操作,偏向锁的代价(引入了许多复杂的代码)已经大于其带来的性能提升

轻量级锁

  • 从偏向锁转换为轻量级锁的场景:从偏向锁升级为轻量级锁的场景就是,线程A作为当前偏向锁的持有者,此时线程B开始尝试获取偏向锁,但是CAS设置失败了(因为偏向于线程A)此时如果检查线程A存活,并且查询栈帧信息也发现线程A当前确实仍旧需要持有此锁,则撤销偏向锁并升级为轻量级锁
  • 轻量级锁相对于重量级锁的优势就是在于,未竞争得到锁的线程不会像重量级锁那样,直接进入阻塞等待唤醒(状态切换的性能代价比较大),而是再挣扎着使用CAS自旋,看看在有限的自旋中能否获得锁
  • 轻量级锁的适用场景:线程交替执行同步块,绝大部分的锁在整个同步周期内都不存在长时间的竞争
轻量级锁的加锁与解锁

image-20210525214532119

  1. 如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面(不管是否获得了此锁,只要在尝试获取时发现锁是轻量级锁就要做此事)

JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word,即把本线程要获得的锁(对象)的对象头中的Mark Word赋值一份到自己的栈帧(个人猜测应该是栈顶栈帧)中

  1. 然后线程尝试用CAS将锁的Mark Word替换为指向当前线程栈帧的锁记录的指针。如果成功,当前线程获得锁,如果失败,检查Mark Word中的指针是否指向本线程的锁记录,如果是,表示是重入,那么设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用,否则表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁

自旋:不断尝试去获取锁,一般用循环来实现

  • 自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源,降低系统的吞吐量。

  • 解决这个问题最简单的办法就是指定自旋的次数

    • 例如让其循环10次,如果还没获取到锁就进入阻塞状态(锁升级)。
    • JDK采用了更聪明的方式——适应性自旋简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少
  • 自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。同时这个锁就会升级成重量级锁

  1. 在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程(此时已是重量级锁了)

重量级锁

  • 重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU(相对于自旋来说)

    image-20210510190948103

  • 当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程

    • Contention List:所有请求锁的线程将被首先放置到该竞争队列
    • Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
    • Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
    • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
    • Owner:获得锁的线程称为Owner
    • !Owner:释放锁的线程
  • 非公平的体现(重量级锁中的自旋)

    当线程释放锁时,会从Contention List或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁。这是因为对于重量级锁,线程先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销。如果自旋不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平,还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁(当然处于Runnable状态的锁(严格来说自旋的线程与就是Runnable状态的线程)也会与等待半天终于有机会的线程竞争锁,这也是不公平的体现

    为什么要在重量级锁中引入自旋锁呢?

    线程的阻塞与唤醒是很消耗性能的操作,所以要尽力不让没有抢到锁的线程阻塞

    如果正在持有锁的线程在很短的时间内释放锁资源,那么进入阻塞状态的线程被唤醒后又要重新抢占锁资源

    JVM提供了自旋锁,可以通过自旋的方式不断尝试获取锁,从而_避免线程被挂起阻塞,只不过由此引入了不公平的因素罢了

  • 如何从等待队列中选出假定继承人呢?

    • Java 虚拟机如何从一个锁的入口集中选择一个等待线程,作为下一个可以参与再次申请相应锁的线程,这个细节与 Java 虚拟机的具体实现有关:这个被选中的线程有可能是入口集中等待时间最长的线程,也可能是等待时间最短的线程,或者完全是随机的一个线程
  • 在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能,一旦锁竞争激烈或者锁占用的时间过长,自旋锁将会导致大量的线程一直处于CAS重试状态,占用CPU资源在高并发的场景下,可以通过关闭自旋锁来优化系统性能

  • 需要注意的是,当调用一个锁对象的waitnotify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

各种锁的优缺点对比

image-20210525213145744

  • 轻量级锁与重量级锁是从响应时间吞吐量两个角度来区分优势和劣势

synchronized与ReentrantLock(基于Lock接口)的区别是什么

  • 相同点,两者都是可重入锁

    “可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁

  • Lock同步锁是基于Java实现的(JDK层面),而synchronized是基于底层操作系统的Mutex Lock实现的(JVM层面的实现),每次获取和释放锁都会带来用户态和内核态的切换,从而增加系统的性能开销,但是在JDK 1.6后,Java对synchronized同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了Lock同步锁

    • ReentrantLock是基于AQS实现的
  • 相比于synchronized,ReentrantLock增加了三个高级功能

    • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

    • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

    • 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。

      Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。关于Condition机制的使用可以参考ThreadPoolExecutor类中的awaitTermintaed方法

  • 实际上使用Lock接口的各种锁还有一点比较麻烦的就是,必须先获取锁,然后将同步代码放到try-finally代码块中,并且必须在finally中释放锁,而synchronized的异常条件下的锁的释放是自动维护的(从字节码可以看到)

  • 自己的补充:从锁的性质与粒度等等,基于Lock接口的各种锁的实现时丰富多样的,而对于Synchronized要保证其实现并发编程的三大性质的话,需要保证以下两点

    • 这些线程在访问同一组共享数据的时候必须使用同一个锁(如果锁对象不是同一个的话,共享对象可以被多个持有不同锁的线程并发访问
    • 这些线程中的任意一个线程,即使其仅仅是读取这组共享数据而没有对其进行更新的话,也需要在读取时持有相应的锁(即便是只读也要保证加锁,这在部分Lock接口的锁看来应该是不可理喻的,但是对于synchronized来说,不仅仅保证原子性,也需要保证可见性,所以读的时候也必须加锁,否则读到的未必是最新的数据)

对于volatile关键字的理解

  • volatile解决的是并发编程中的可见性和有序性问题,个人理解,可以把volatile当做是更细粒度的更轻量级的synchronized的实现

  • volatile关键字保证有序性和可见性的本质原因是JMM使用内存屏障来实现的

    • JMM不仅仅是一个模型描述,其中也包含了JVM层面对于内存读写的控制,synchronized,volatilefinal等关键字起作用都依赖于JMM这个规则,JMM可以保证在正确使用(所谓的正确使用就是程序员遵循JMM提供的happens-before原则)以上三个关键字的条件下可以实现多线程下的同步

      • 关于happens-bufore原则参考什么是happens-before原则,简单来说就是如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程

        1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
        2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。

可见性问题的由来-从CPU缓存与JMM内存模型讲起

  • CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题,正如内存是为了解决CPU处理速度与硬盘响应速度不对等的问题一样

    image-20210512115756698
  • 有缓存就会有缓存不一致问题的存在,典型的就是i++问题

    比如我执行一个 i++操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3

  • JVM中对于运行时内存进行了抽象,抽象过后的模型就是JMM模型

    • JMM模型:所有变量都存储在主内存,线程均有自己的本地内存。 本地内存中保存被该线程使用的变量的主内存副本线程对变量的所有操作都必须在本地内存中进行,不能直接读写主内存数据。操作完成后,线程的工作内存通过缓存一致性协议将操作完的数据刷回主存

      • 屏蔽掉了各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致性的内存访问效果
    • 线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写,如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致

      本地内存是Java内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等

      image-20210512120519239
  • volatile单词本身的含义就是不稳定的易变的,对于用此关键字修饰的变量,线程对其进行读写时会直接对主内存进行操作,而不是本地内存,这保证了线程A对于该变量的修改可以在其他线程立即可见

    • 在并不极端的情况下,在x86的架构下,JVM回写主内存的速度非常快,即便不使用volatile也不会有太大的影响,但是,换成ARM的架构,就会有显著的延迟
  • 关于JMM与运行时内存划分的区别

    • 运行时内存划分是JVM运行时对于内存空间进行的必要的划分,而JMM是一组规则,这些规则用来控制基于原子性、可见性、有序性的变量访问
    • 都存在私有数据区域和共享数据区域。一般来说,JMM中的主内存属于共享数据区域,他是包含了堆和方法区;同样,JMM中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈

有序性问题

  • CPU为了提升性能,减少中断时间,引入了指令重排的技术,但是此技术的引入只保证单线程下的串行语义一致,并发编程的话就导致出现了有序性问题

    • 在有序性方面有两个原则,as-if-serialhappens-before原则,前者只保证在单线程下的逻辑一致性,后者保证正确执行同步(所谓正确执行同步就是正确的使用了一系列的同步机制,关键字等等)的多线程程序的逻辑一致性
    • As-if-serial:编译器等会对原始的程序进行指令重排序和优化。但不管怎么重排序,其结果和用户原始程序输出预定结果一致
  • volatile是如何解决(通过内存屏障)的?

    1. 如果第一个操作是volatile读,那无论第二个操作是什么,都不能重排序;
    2. 如果第二个操作是volatile写,那无论第一个操作是什么,都不能重排序;
    3. 如果第一个操作是volatile写,第二个操作是volatile读,那不能重排序。

volatile与synchronized的区别

  • 可以把volatile当做是更细粒度的更轻量级的synchronized的实现,volatile的性能比synchronized要好,但是volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块

  • 从有序性上来说,volatile通过内存屏障直接防止指令重排以实现有序性,而sync是通过互斥锁(本质上也是内存屏障)来实现有序性,也就是在持有锁单线程内可以执行任意的指令重排,但是其结果对于其他线程来说是一致的

    • 本质上说,sync实现有序性是在临界区的边界实现的有序性,实际上起作用的还是内存屏障(sync内部同样使用内存屏障实现了可见性),具体的参考下边的对于内存屏障的理解
  • 从可见性上说,**volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性(或者说是同步代码块整体的可见性)**

    • volatilesynchronized都使用内存屏障实现可见性,对于synchronzied来说,加锁时会强制读取主内存的最新的数据,释放锁时会将新的值刷新回主内存—确实是这样的,参考Synchronized与内存屏障Java内存模型基础知识

      • 对于内存屏障的理解

        内存屏障是对一类仅针对内存读、写操作指令 ( Instruction ) 的跨处理器架构 ( 比如 x86 、ARM )的比较底层的抽象( 或者称呼 )。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性

        为了实现禁止重排序的功能,这些指令也往往需要刷新处理器缓存、冲刷处理器缓存,从而间接保证了可见性

        按照有可见性保障来划分,内存屏障可分为加载屏障(Load Barrier)和存储屏障(Store Barrier)。加载屏障的作用是刷新处理器缓存,存储屏障的作用冲刷处理器缓存Java虚拟机会在 MonitorExit ( 释放锁 ) 对应的机器码指令之后插入一个存储屏障,这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的。相应地,Java 虚拟机会在 MonitorEnter ( 申请锁 ) 对应的机器码指令之后临界区开始之前的地方插入一个加载屏障,这使得读线程的执行处理器能够将写线程对相应共享变量所做的更新从其他处理器同步到该处理器的高速缓存中。因此,可见性的保障是通过写线程和读线程成对地使用存储屏障和加载屏障实现的

        按照有序性保障来划分,内存屏障可以分为获取屏障(Acquire Barrier)和释放屏障 ( Release Barrier ),Java虚拟机会在 MonitorEnter( 它包含了读操作 ) 对应的机器码指令之后临界区开始之前的地方插入一个获取屏障,并在临界区结束之后 MonitorExit ( 它包含了写操作 ) 对应的机器码指令之前的地方插入一个释放屏障

        获 取 屏 障 的 使 用 方 式 是 在 一 个 读 操 作 ( 包括 Read-Modify-Write 以及普通的读操作 )之后插入该内存屏障,其作用是禁止该读操作与其后的任何读写操作之间进行重排序

        释放屏障的使用方式是在一个写操作之前插入该内存屏障,其作用是禁止该写操作与其前面的任何读写操作之间进行重排序

        注意以上说的按照不同的保障进行了划分,但是实际上内存屏障只有两种 load(Acquire) 、store(Release)

        由于获取屏障禁止了临界区中的任何读、写操作被重排序到临界区之前的可能性。而释放屏障又禁止了临界区中的任何读、写操作被重排序到临界区之后的可能性。因此临界区内的任何读、写操作都无法被重排序到临界区之外,又因为原子性与可见性的加持下,写线程在临界区中对各个共享变量所做的更新会同时对读线程可见,至于临界区内部的执行顺序也变得无所谓了,可以执行重排序

        image-20210525221427134

  • 从原子性上来说,synchronized可以保证代码块或者函数的原子性,而volatile只能保证对某一个变量读写的原子性

    • 不过,一般都说volatile不能保证数据的原子性

    • volatile带来的可见性使得**volatile变量的写和锁的释放具有相同的内存语义,volatile变量的读和锁的获取具有相同的内存语义**

      如何理解呢?

      1. volatile变量写-》volatile变量的读,这两个操作不能执行重排序,类似于锁的释放必然happens-before与下一次锁的获得
      2. volatile变量写之后,直接写到主内存随所有线程可见,类似于锁释放后,同步代码块内的所有操作刷新到主内存,对所有线程可见
      3. volatile变量的读同理,类似于加锁,会强制读取主内存的新的数据到本地内存进行操作

      使用volatile关键字仅能实现对原始变量(如boolen、 short 、int 、long等)操作的原子性,但需要特别注意, volatile不能保证复合操作的原子性,即使只是i++,实际上也是由多个原子操作组成:read i; inc; write i,假如多个线程同时执行i++,volatile只能保证他们操作的i是同一块内存,但依然可能出现写入脏数据的情况,参考单例模式的解析,对于复杂类型只在指令层面具有原子性

final关键字在并发编程中的理解—安全发布

  • 对于final的理解不要停留在编译阶段的限制上,因为在JVM层面也有专为final设计的实现(不知道其他的关键字如public等是如何实现的,猜测也是有对应的JVM实现的,毕竟在编译阶段可以阻止不合规的编码,并且在编译后的字节码中也有对应的flag标识

关于final的基础了解

  • final修饰成员变量(非静态)时,可以有两种初始化方式(对于局部常量必须在声明时初始化)
    • 直接在声明位置进行初始化
    • 只声明,然后在该类的所有构造函数进行初始化
  • 使用final修饰方法的原因有两个(两个特征)
    • 不会被子类篡改
    • 效率更高,因为在编译时就被确定了(属于解析调用)不需要在运行时进行动态的分派调用(final修饰的方法是非虚方法)
    • 被private修饰的方法会被默认加上final修饰符
  • 被final修饰的类的成员变量可以根据选择设置为是否final,但是final修饰的类的所有方法都被隐式的指定为final方法

final与安全发布

  • 什么是安全发布
    • 所谓的安全发布就是在并发编程中,某线程安全的初始化一个对象后,该对象的引用作为共享变量才能被其他线程使用
      • 如同volatile在双重验证的单例模式中起作用的功能分析中所说的那样,new关键字创建一个对象要分为好几个原子步骤,这个过程不是同步进行的,如果进行了重排序就会导致其余线程使用到未初始化的对象,这是不被允许的
  • 如何解决安全发布的问题
    • 使用volatile修饰引用类型的变量
    • 使用final修饰引用类型的变量(针对的肯定是非静态变量)
      • 在构造函数内对一个final域的写入(初始化),与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
      • 一旦对象引用对其他线程可见,则其final成员也必须正确的赋值
      • final实现安全发布的原理同样是JVM对final修饰的实例变量加上了适当的内存屏障
    • 加锁
    • 使用AtomicReference 类型的变量
    • 将成员变量升级为静态变量,或者在静态代码块中执行发布,其线程安全性由JVM保证

final的使用总结

  • final关键字提高了性能。JVM和Java应用都会缓存final变量
  • final变量可以安全的在多线程环境下进行共享,而不需要额外的同步开销
    • 局部变量(引用类型的局部变量不存在引用逃逸的情况下)与final修饰的变量是天然的线程安全的
  • 使用final关键字,JVM会对方法、变量及类进行优化
  • final关键字可以用于成员变量、本地变量、方法以及类
  • 接口中声明的所有变量本身是final的
  • 按照Java代码惯例,final变量就是常量,而且通常常量名要大写
  • 因为final与volatile有类似的内存语义,所以两者不能同时使用

ThreadLocal的理解

  • 源码级别的理解可以参考JVM垃圾回收文章中的引用的分类这个章节

ThreadLocal概述

  • 为什么需要ThreadLocal,这是因为在整个并发编程中,使用的是共享内存的并发模型,线程仅的通信使用的都是共享变量,那么线程是否可以持有属于自己的私有变量(注意这里的是整个线程层面的私有变量,与函数中的局部变量不是一个层级)呢?threadLocal的作用就是为每个线程维护专属于该线程自己的私有变量,常常将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据,但是实际上的本质是,每个线程自己维护一个由ThreadLocal实例做为key的键值对(ThreadLocalMap类型–ThradLocal的静态内部类),以实现线程私有变量的维护,ThreadLocal只是提供了一个读写的接口罢了

    image-20210518133927417
  • 使用ThreadLocal的具体场景

    • 比如在使用Redis做分布式锁,要实现其可重入时,可以用到ThreadLocal,存储一个map,key就是Redis key value就是重入的次数
    • 需要多次传递的参数(公共参数),比如前段的一个特定的标识信息,需要多层级中使用,为了避免参数的层层传递,直接存储在线程中,需要参数的时候直接从线程的ThreadLocal中取即可

补充TheadLocal知识

  • ThreadLocal支持泛型,标识ThreadLocal维护的线程私有值的类型

  • ThreadLocal支持使用withInitial方法提供初始化值(在任何子线程修改之前的初始值)

    1
    2
    3
    public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
    return new SuppliedThreadLocal<>(supplier);
    }

线程池

  • 参考独立的总结文章

Atomic原子类

  • 参考独立的总结文章

AQS

  • 参考独立的总结文章

补充

关于可重入与不可重入的认识

  • 可重入锁不是为了解决死锁而提出的,死锁出现的场景比较多,可重入锁仅仅解决了不可重入锁在递归调用时出现的死锁

    • 对于不可重入锁,在首次获得锁之后,再次尝试获取锁时会死锁(相当于自我博弈
  • 使用CAS来模拟可重入锁与不可重入锁

    1
    public class UnreentrantLock {    // 因为构建的是一个锁对象,需要有一个数据结构来存储拥有当前锁的线程		// 使用AtomicReference维护拥有当前锁的线程    private AtomicReference<Thread> owner = new AtomicReference<Thread>();			  	// 上锁    public void lock() {        Thread current = Thread.currentThread();        //这句是很经典的“自旋”语法,AtomicInteger中也有        //这里也是导致不可重入的关键所在        for (;;) {            // 如果当前锁不被任何线程占有,则设置为当前线程            if (owner.compareAndSet(null, current)) {                return;            }        }    }		// 解锁    public void unlock() {        Thread current = Thread.currentThread();        // 解锁时当前线程如果不是锁的占有者便直接退出,不会有任何影响        owner.compareAndSet(current, null);    }}// 通过引入状态计数将上述的不可重入锁变为可重入锁public class ReentrantlockDemo {    private AtomicReference<Thread> reference = new AtomicReference<>();    // 维护一个简单的计数器    private int volatile state;    public void lock () {            Thread current = Thread.currentThread();        if (current == reference.get()) {            // 如果是当前线程重复加锁,就只计数并返回            // 因为确定是当前线程执行的所以此处不用担心线程安全的问题            state ++;            return;        }        // 使用while循环实现的自旋来使用CAS        while(! reference.compareAndSet(null, current));    }    public void unlock () {        // 当前线程释放锁        Thread current = Thread.currentThread();        // 此时必须判断当前线层是否是持有锁的线程        if (current == reference.get()) {            // 判断计数器是否为0,如果为0就是结束嵌套,彻底解锁,否则就是还需要进一步的解锁            if (!(0 == state)) {                state --;            } else {                reference.compareAndSet(current, null);            }        }    }}
  • 除此之外还可以参考AQS文章中的对于可重入与不可重入的实现

关于死锁

  • 概念:

    • 多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。最简单的场景是:两个线程都想获得对方持有的锁(并非同一个锁对象)而导致死锁
  • 案例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    public class DeadLockDemo {
    private static Object resource1 = new Object();//资源 1
    private static Object resource2 = new Object();//资源 2

    public static void main(String[] args) {
    new Thread(() -> {
    synchronized (resource1) {
    System.out.println(Thread.currentThread() + "get resource1");
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println(Thread.currentThread() + "waiting get resource2");
    synchronized (resource2) {
    System.out.println(Thread.currentThread() + "get resource2");
    }
    }
    }, "线程 1").start();

    new Thread(() -> {
    synchronized (resource2) {
    System.out.println(Thread.currentThread() + "get resource2");
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println(Thread.currentThread() + "waiting get resource1");
    synchronized (resource1) {
    System.out.println(Thread.currentThread() + "get resource1");
    }
    }
    }, "线程 2").start();
    }
    }

    死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程

  • 产生死锁的四个必要条件

    • 互斥条件:该资源任意一个时刻只由一个线程占用(sync本身基于互斥锁实现)
    • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放
      • Java线程可以同时持有多个锁
    • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源
    • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
  • 如何避免死锁

    • 对于需要获取多个锁对象的状态:线程获取锁的顺序要一致,避免犬牙交错的状态出现

      在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

      安全状态指的是系统能够按照某种进行推进顺序(P1、P2、P3…..Pn)来为每个进程分配所需资源,直到满足每个进程对资源的最大需求,使每个进程都可顺利完成。称<P1、P2、P3…..Pn>序列为安全序列

    • 对于单个锁的使用场景下:使用可重入锁(实际上打破了互斥条件)

    • ReentrantLock可以设置等待锁的时间,从而避免出现死锁

    • 使用乐观锁

并发编程算法题

参考

  1. 2020最新Java并发进阶常见面试题总结
  2. Java多线程安全之构造函数
  3. synchronized与锁
  4. Synchronized与内存屏障