基础知识

形成死锁的四个必要条件是什么

  1. 互斥:线程对于所分配到的资源具有排他性,即一个资源只能被一个线程占用,直到该线程释放;
  2. 请求与保持:一个线程因请求被占用资源而发生阻塞时,对已获得的资源保持不放;
  3. 不剥夺:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源;
  4. 循环等待:当发生死锁时,所等待的线程必定会形成一个环路,造成永久阻塞。

创建线程的4种方式

  1. 继承Thread类
  2. 实现Runnable接口
  3. 实现Callable接口
  4. 使用Executors工具类创建线程池
public class ThreadTest {
    /**
     * 方法一:继承Thread
     */
    class MyThread extends Thread {

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + "继承Thread的线程正在工作");
        }
    }

    /**
     * 方法二:实现Runnable
     */
    class MyRunnable implements Runnable {

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + "实现Runnable的线程正在工作");
        }
    }

    /**
     * 方法三:实现Callable
     */
    class MyCallable implements Callable<Integer>{

        @Override
        public Integer call() throws Exception {
            System.out.println(Thread.currentThread().getName() + "实现Callable的线程正在工作");
            return 1;
        }
    }

    /**
     * 方法四:利用Executors工具类创建线程池
     */
    class MyRunnable2 implements Runnable{

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + "Executors创建的线程池正在工作");
        }
    }

    @Test
    public void test(){
        //创建线程
        MyThread myThread = new MyThread();

        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);

        FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());
        Thread callableThread = new Thread(futureTask);

        ExecutorService executorService = Executors.newSingleThreadExecutor();
        MyRunnable2 myRunnable2 = new MyRunnable2();

        //开启线程
        myThread.start();
        thread.start();
        callableThread.start();
        executorService.execute(myRunnable2);
    }
}

线程的状态和基本操作

在这里插入图片描述

  1. 新建(new):新创建了一个线程对象。

  2. 可运行(runnable):线程对象创建后,当调用线程对象的start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。

  3. 运行(running):可运行状态(runnable)的线程获得了CPU时间片(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;

  4. 阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。

    阻塞的情况分三种:

    • 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
    • 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
    • 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
  5. 死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

sleep() 和 wait() 有什么区别?

  • 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是Object类的方法。
  • 是否释放锁:sleep() 不释放锁;wait() 释放锁。
  • 用途不同:wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行
  • 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法,当然也可以使用wait(long timeout)超时后线程会自动苏醒。sleep() 方法执行完成后,线程会自动苏醒。

为什么要将wait()方法放在while循环中

因为处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。

Thread类的yield方法有什么作用?

使当前线程从运行状态转为就绪状态,以便给相同优先级或更高优先级的线程运行的机会。

sleep()和yield()方法为什么是静态的?

Thread类的sleep()和yield()方法都为静态方法,说明只有正在执行的线程才能执行,在其他处于等待状态的线程上调用这些方法是没有意义的,如果设置为非静态的,那么程序员就可能错误的认为可以在其他非运行线程上调用这些方法。

sleep、yield、join、wait的比较

  1. Thread.sleep(long millis),一定是当前线程调用此方法,当前线程进入阻塞,但不释放对象锁,millis后线程自动苏醒进入可运行状态

    作用:给其它线程执行机会的最佳方式。

  2. Thread.yield(),一定是当前线程调用此方法,当前线程放弃获取的cpu时间片,由运行状态变会可运行状态,让OS再次选择线程。作用:让相同优先级的线程轮流执行,但并不保证一定会轮流执行。实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。Thread.yield()不会导致阻塞。

  3. t.join()/t.join(long millis),让调用该方法的线程在执行完run()方法后,再执行join方法后面的代码。具体而言,可以通过线程t的join()方法来等待线程t的结束,或者使用线程t的join(millis)方法来等待线程t的结束,但最多只等待2ms。

  4. obj.wait(),当前线程调用对象的wait()方法,当前线程释放对象锁,进入等待队列。依靠notify()/notifyAll()唤醒或者wait(long timeout),timeout时间到自动唤醒。

  5. obj.notify()唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待的所有线程。

wait(),notify(),notifyAll()

  • 如果一个线程调用了对象的wait()方法,那么线程便会进入该对象的等待池中,等待池中的线程不会去竞争该对象的锁;
  • notifyAll()会唤醒所有的线程,notify()只会唤醒一个线程;
  • notifyAll()调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争,notify()只会唤醒一个线程,具体唤醒哪个线程由虚拟机控制。

wait和notify底层实现原理

wait方法实现

lock.wait()方法最终通过ObjectMonitor的void wait(jlong millis, bool interruptable, TRAPS);实现:

ObjectMonitor对象中有两个队列:WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表;

_WaitSet ** :处于wait状态的线程,会被加入到wait set;
**_EntryList
:处于等待锁block状态的线程,会被加入到entry set;

1、将当前线程封装成ObjectWaiter对象node

img

ObjectWaiter对象是双向链表结构,保存了_thread(当前线程)以及当前的状态TState等数据, 每个等待锁的线程都会被封装成ObjectWaiter对象。

2、通过ObjectMonitor::AddWaiter方法将node添加到_WaitSet列表中;

img

3、通过ObjectMonitor::exit方法释放当前的ObjectMonitor对象,这样其它竞争线程就可以获取该ObjectMonitor对象。

img

4、最终底层的park方法会挂起线程;

notify方法实现

lock.notify()方法最终通过ObjectMonitor的void notify(TRAPS)实现:
1、如果当前_WaitSet为空,即没有正在等待的线程,则直接返回;
2、通过ObjectMonitor::DequeueWaiter方法,获取_WaitSet列表中的第一个ObjectWaiter节点,实现也很简单。
这里需要注意的是,在jdk的notify方法注释是随机唤醒一个线程,其实是第一个ObjectWaiter节点

img

3、根据不同的策略,将取出来的ObjectWaiter节点,加入到_EntryList或则通过Atomic::cmpxchg_ptr指令进行自旋操作cxq有兴趣的同学可以看objectMonitor::notify方法;

notifyAll方法实现

lock.notifyAll()方法最终通过ObjectMonitor的void notifyAll(TRAPS)实现:
通过for循环取出_WaitSet的ObjectWaiter节点,并根据不同策略,加入到EntryList或则进行自旋操作。

从JVM的方法实现中,可以发现:notify和notifyAll并不会释放所占有的ObjectMonitor对象,其实真正释放ObjectMonitor对象的时间点是在执行monitorexit指令,一旦释放ObjectMonitor对象了,entry set中ObjectWaiter节点所保存的线程就可以开始竞争ObjectMonitor对象进行加锁操作了。

线程中断

线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程,有人希望你退出了。至于目标线程接到通知后如何处理,则完全由目标线程自行决定。

  • Thread.interrupt()方法是一个实例方法,它通知目标线程中断,也就是设置中断标志位,表明当前线程已经被中断了;
  • Thread.isInterrupted()方法也是实例方法,它通过检查中断标志位来判断当前线程是否被中断;
  • Thread.interrupted()方法是静态方法,它也用来判断当前线程是否被中断,但是同时也会清除当前线程的中断标志位状态。

线程同步和互斥

  • 当一个线程对共享的数据进行操作时,应使之成为一个“原子操作”,即在没有完成相关操作之前,不允许其他线程打断它,否则就会破坏数据的完整性,这就是线程的同步;
  • 线程互斥是指对于共享的进程资源,当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用该资源的线程释放资源;
  • 线程互斥可以看成是一种特殊的线程同步。

Java锁有哪些种类,以及区别

img

1. 乐观锁VS悲观锁

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

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

悲观锁适合写多读少的场景,乐观锁适合读多写少的场景。

CAS:全称为Compare And Swap,需要涉及到三个操作数:

  • 需要读写的内存值V;
  • 进行比较的值A;
  • 要写入的值B;

当且仅当V的值等于A时,CAS通过原子方式将新值B来更新V的值,否则不会执行任何操作。

ABA问题:CAS需要在操作值的时候检查内存值是否发生变化,没有变化才会更新内存值,但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有变,实际上是有变化的。

解决办法:J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。

2. 自旋锁VS适应性自旋锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理时间,在很多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费有可能比用户代码执行的时间还要长,如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。这个等待的过程就是让当前线程自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。

自旋等待虽然避免了线程切换的开销,但是它仍占用处理器时间,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源,所以自旋等待的时间必然有一定的限度,超过该限度后应该挂起线程。

自旋锁的实现原理同样也是CAS。

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

3. 无锁VS偏向锁VS轻量级锁VS重量级锁

这四种锁是指锁的状态,专门针对synchronized的,且锁的状态只能升级不能降级。

为什么synchronized能实现线程同步?

在回答这个问题之前,我们需要先了解两个重要概念:Java对象头Monitor

Java对象头

synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,以HotSpot虚拟机为例,其对象头主要包括两部分数据:Mark Word(标记字段)Klass Pointer(类型指针)

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息,在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象,每个Java对象都有一把看不见的锁,称为内部锁或者Monitor锁。Monitor是线程私有的数据结构,里面的Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

无锁

无锁就是没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功;

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源,如果没有冲突就修改成功,否则就会继续循环尝试。

偏向锁

在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁,偏向锁是指一段同步代码一直被一个线程所访问,那么该线程在进入和退出同步块时不再通过CAS操作来加锁和解锁,而是直接获取锁,降低获取锁的代价。

轻量级锁

轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

重量级锁

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

综上:偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作,而轻量级锁是通过CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能;重量级锁是将除了拥有锁的线程以外的线程都阻塞。

4. 公平锁VS非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁,公平锁的优点是等待锁的线程不会饿死,缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是在还没进入队列之前可以与队列中的线程竞争尝试获取锁,获取不到才会进入等待队列的队尾等待,但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所有非公平锁有可能出现后申请锁的线程先获取锁的场景,非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程,缺点是处于等待队列中的线程有可能会饿死,或者等很久才会获得锁。

公平锁的lock方法在进行CAS判断时多了一个hasQueuedPredecessors()方法,它会在AQS队列中没有线程的情况下才会申请锁,而不像非公平锁一样,非公平锁不管AQS里是否有排队的线程就直接申请锁。

5. 独享锁VS共享锁

独享锁也叫排它锁,是指该锁一次只能被一个线程所持有,如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁,获得排它锁的线程既能读数据又能修改数据,JDK中的synchronized和JUC中Lock的实现类就是独享锁;

共享锁是指该锁可被多个线程所持有,如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁,获得共享锁的线程只能读数据,不能修改数据。

协程

  • 协程(Coroutines)是一种比线程更加轻量级的存在,正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。
  • 协程既不是进程也不是线程,协程仅仅是一个特殊的函数。
  • 一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用CPU多核能力。
  • 协程和进程一样,切换是存在上下文切换问题的,协程的切换内容是硬件上下文,切换内存保存在用户自己的变量中,协程的切换过程只有用户态,因此切换效率高。

一个进程内的线程数量的限制是什么?

创建一个线程会占用多少内存,取决于分配给线程的调用栈大小,可以用ulimit -s命令来查看大小,Linux默认情况下启动一个子线程需要分配10MB的线程栈空间,为了降低进程内存占用,就必须将默认值修改,在Windows下该默认值一般为1MB,通过修改编译选项可以修改。

进程最多可以创建的线程数是根据分配给调用栈的大小,以及操作系统(32位和64位不同)共同决定的。

并发理论

Java内存模型

线程间如何实现通信以及如何同步?

  • 线程之间的通信机制有两种:共享内存消息传递
  • 在共享内存的并发模型中,线程之间通过写-读内存中的公共状态来隐式进行通信,而在消息传递的模型中,线程之间必须通过明确的发送消息来显式进行通信;
  • Java采用的是共享内存的方式来实现通信,通过synchronized和lock关键字加锁的方式来实现同步。

谈谈你对Java内存模型的理解

处理器和内存处理速度不是同数量级,所以需要在中间建立中间层,也就是高速缓存,这会引出缓存一致性问题。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),有可能操作同一位置引起各自缓存不一致,这时候需要约定协议在保证一致性。
Java 内存模型(Java Memory Model,JMM):屏蔽掉了各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致性的内存访问效果。

在这里插入图片描述

Java内存区域和Java内存模型区别

Java内存区域:

在这里插入图片描述

  • 方法区
    主要用于存储已被虚拟机加载的类信息常量静态变量、即时编译器编译后的代码等数据,方法区里面有一个运行时常量池,用于存放编译器生成的各种字面量符号引用
  • JVM堆
    主要用于存放对象实例,是垃圾收集器管理的主要区域。
  • 程序计数器
    代表了当前线程所执行的字节码行号指示器。
  • 虚拟机栈
    代表了Java方法执行的内存模型,每个方法执行时都会创建一个栈帧来存储方法的变量表、操作数栈、动态链接方法、返回值、返回地址等信息,每个方法从调用到结束对应了一个栈帧在虚拟机栈中的入栈和出栈过程。
  • 本地方法栈
    和本地方法有关。

Java内存模型:
是一种抽象的概念,并不真实存在,用于定义程序中各个变量的访问方式。
在这里插入图片描述
JVM运行程序的实体是线程,每个线程创建时,JVM都会为其创建一个工作内存,用于存储线程私有的数据,而JVM内存模型中规定所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作必须在工作内存中进行
因此首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本,因为无法访问其他线程的工作线程,所以线程之间的通信必须通过主内存来完成。

JMM存在的必要性

如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题,JMM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,JMM是围绕着程序执行的原子性,有序性,可见性展开的,对于原子问题,JMM自身提供了对基本数据类型读写操作的原子类型,可见性问题可以通过synchronized或者volatile关键字来解决,Happens-before原则也保证了多线程环境下两个操作间的可见性、有序性,同时volatile还能禁止指令重排,synchronized和Lock操作来实现有序性。

as-if-serial规则和happens-before规则

as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

as-if-serial语义和happens-before都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

happens-before规则:

happens-before是Java内存模型最核心的概念,在设计Java内存模型时,需要考虑两个关键因素:程序员对内存模型的使用以及编译器和处理器对内存模型的实现。程序员希望内存模型易于理解、易于编程,因此希望基于一个强内存模型来编写代码;而编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。

happens-before规则的目的就是为程序员提供足够强的内存可见性保证,同时对编译器和处理器的限制尽可能地放松。

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

happens-before规则有哪些:

  • 程序顺序原则:同一个线程内必须按照代码顺序执行;
  • 锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;
  • volatile规则:volitile变量的“写”先发生于“读”
  • 线程启动规则:线程的start()方法先于它的每个动作
  • 线程终止规则:线程的所有操作先于线程的结束
  • 线程中断规则:对线程interrupt()方法的调用先于被中断线程的代码检测到中断事件的发生
  • 传递性:A先于B,B先于C,那么A必然先于C。

并发关键字

synchronized

synchronized关键字的三种使用方式

  • 修饰实例方法
  • 修饰静态方法
  • 修饰代码块
public class SynchronizedUseTest {
    private static int sum = 0;
    /**
     * synchronized修饰实例方法
     */
    class AccountSync implements Runnable{

        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                increase();
            }
            //使用同步代码块进行同步操作,所对象是instance
            synchronized(this){
            	for(int i = 0; i < 10000; i++){
            		sum++;
            	}
            }
        }

		//如果修饰的是实例方法,那么只有同一对象实例绑定的不同线程访问才能输出正确结果
		//如果修饰的是类方法,只要是当前类下的实例,都能正常得到正确结果
        private synchronized void increase() {
            sum++;
        }
    }
    @Test
    public void test() throws InterruptedException {
        AccountSync instance = new AccountSync();
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);//

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(sum);
    }
}

synchronized底层语义原理

  • Java虚拟机中的同步是基于进入和退出管程(Monitor)对象实现的。
  • 在JVM中,对象在内存中的布局分为:对象头、实例数据和对齐填充三部分,实例数据用来存放类的属性数据信息,Java对象头中就存放了synchronized使用的锁对象;
  • synchronized修饰的代码块的实现使用的是monitorentermonitorexit指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
  • synchronized修饰的方法通过ACC_SYNCHRONIZED标识来指明该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
  • 重入锁是因为底层维护了一个计数器,当计数器值为0时,表明该锁未被任何线程所持有,其他线程可以竞争获得锁。

使用synchronized关键字实现单例模式

public class SingletonBySynchronized {
	//因为指令重排的特性,所以这里必须加volatile修饰
    private volatile static SingletonBySynchronized uniqueInstance;
    
    public SingletonBySynchronized() {
    }

    public static SingletonBySynchronized getUniqueInstance() {
        if(uniqueInstance == null){
            synchronized (SingletonBySynchronized.class){
                if(uniqueInstance == null){
                    uniqueInstance = new SingletonBySynchronized();
                }
            }
        }
        return uniqueInstance;
    }
}

锁优化

锁消除
通过逃逸分析来对检测出不可能存在竞争的共享数据的锁进行消除,如果堆上的共享数据不可能逃逸出去被其他线程访问到,那么就可以把它们当做私有数据来对待,从而消除锁。

偏向锁
偏向锁是JDK6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。

偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提高程序的性能。

所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这种场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

轻量级锁
轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

这是因为很多synchronized里面的代码只是一些很简单的代码,执行时间非常快,此时如果所有等待的线程都进入阻塞队列,那么会产生用户态和内核态切换的问题,造成的开销非常大,因此可以在synchronized 的边界做忙循环(自旋),如果循环多次还没有获得锁再阻塞,由于忙循环也要占用CPU时间,所以自旋锁只适用于共享数据的锁定状态很短的场景。

锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。如果一串零碎的操作都是对同一个对象加锁,可以将加锁的范围扩展,提高性能。

线程中断与synchronized

“中断”是指线程在运行过程中打断其运行,在Java中,提供了3个有关线程中断的方法:

//中断线程(实例方法)
public void Thread.interrupt();
 
//判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();
 
//判断线程是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();

当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用Thread.interrupt()方法可以中断该线程,此时将抛出一个InterruptedException异常,同时中断状态将会被复位(由中断状态变为非中断状态)

public class InterruptSleepThread {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(){
            @Override
            public void run() {
                try {
                    while(true){
                        TimeUnit.SECONDS.sleep(2);
                        System.out.println("没有进入中断");
                    }
                } catch (InterruptedException e) {
                    System.out.println("Interrupted When Sleep");
                    boolean interrupted = this.isInterrupted();
                    System.out.println("interrupt:"+interrupted);
                }
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();
    }
}

在这里插入图片描述
如果是运行期非阻塞状态的线程,那么直接调用Thread.interrupt()中断线程是不会得到响应的,因为处于非阻塞状态的线程需要手动进行检测并结束程序,如下所示:

public class InterruptSleepThread {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(){
            @Override
            public void run() {
                while(true){
                    if (this.isInterrupted()) {
                        System.out.println("线程中断");
                        break;
                    }
                }
                System.out.println("已跳出循环,线程中断");
            }
        };
        t1.start();
        TimeUnit.SECONDS.sleep(2);
        t1.interrupt();
    }
}

在这里插入图片描述
总结:

  • 当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将抛出interruptException异常(该异常必须捕获,无法向外抛出),并将中断状态复位;
  • 当线程处于运行状态时,我们也可以调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码(结束run方法的代码)

volatile

Java提供了volatile关键字来保证内存可见性禁止指令重排,volatile提供happens-before的保证,确保一个线程的修改能对其他线程是可见的,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

volatile不能保证原子性,所以一般与CAS结合使用

volatile原理,为什么能保证共享变量可见性

为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升,因为多级缓存就有可能导致缓存数据不一致问题。

对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中,根据缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器要对这个数据进行修改操作的时候,就会强制重新从系统内存里把数据读到处理器缓存中。

volatile如何禁止指令重排

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,有下面四种策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障

    保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。

  • 在每个volatile写操作的后面插入一个StoreLoad屏障

    避免volatile写与后面可能有的volatile读/写操作重排序;

  • 在每个volatile读操作的后面插入一个LoadLoad屏障

    用来禁止处理器把上面的volatile读与下面的普通读重排序

  • 在每个volatile读操作的后面插入一个LoadStore屏障

    禁止处理器把上面的volatile读与下面的普通写重排序

synchronized和volatile的区别是什么

synchronized表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程;
volatile表示变量在CPU的寄存器中是不确定的,必须从主存中读取,保证多线程环境下变量的可见性,以及禁止指令重排序。

主要区别如下:

  • volatile是变量修饰符;synchronized可以修饰类,方法,变量,代码块;
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性;
  • volatile不会造成线程的阻塞,synchronized可能会造成线程的阻塞;
  • volatile标记的变量不会被编译器优化,synchronized标记的变量可以被编译器优化;

final和并发的关系

不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。

Lock体系

Synchronized与Lock的区别

存在层次:

  • synchronized属于JVM层面;

  • Lock是一个接口,属于JDK层面;

锁的释放:

  • synchronized一旦获取锁的线程执行完同步代码,会自动释放锁,当线程发生异常也会释放锁;

  • Lock必须在finally中手动释放锁,不然容易造成线程死锁;

锁的获取:

  • synchronized中,假设A线程获得锁,B线程等待,如果A线程阻塞,B线程会一直等待;
  • Lock有多个获取锁的方式,可以通过tryLock来尝试获取锁,线程可以不用一直等待;

锁状态:

  • synchronnized:无法判断
  • Lock:可以判断

锁类型:

  • synchronized:可重入,不可中断,非公平
  • Lock:可重入,可判断,可公平

性能:

  • synchronized:少量同步
  • Lock:可以提高多个线程进行读操作的效率(通过readwriteLock实现读写分离),在资源竞争不是很激烈的情况下,synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;ReentrantLock还提供有时间限制的同步,可以被中断的同步等。

Lock和synchronized的比较

Lock接口比同步方法和同步代码块提供了更具扩展性的锁操作,主要优势有:

  1. 可以使锁更公平
  2. 可以使线程在等待锁的时候响应中断
  3. 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间;
    1. 能够超时获取锁,在指定的截止时间之前获取锁,如果截止时间到了仍未获取锁,则返回;
    2. 可以在不同的范围,以不同的顺序获取和释放锁

乐观锁如何实现

1、使用版本标识来确定读到的数据与提交时的数据是否一致,提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略;
2、CAS,当多个线程尝试使用CAS同时修改同一个变量时,只有其中一个线程能更新变量的值,而其他线程都失败,失败的线程并不会被挂起,而是被告知这次竞争失败了,可以再次尝试。CAS操作包含三个操作数–需要读取的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B),如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置的值更新为新值B,否则处理器不做任何操作

CAS会产生什么问题

1、ABA问题:
JDK1.5的atomic包里提供了一个类AtomicStampedReference来解决ABA问题;
2、循环时间长开销大:
对于资源竞争严重的情况,CAS自旋的概率比较大,会浪费很多CPU资源,效率低于synchronized;
3、只能保证一个共享变量的原子操作:
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候可以使用锁。

死锁、活锁、饥饿的区别

  • 死锁:是指两个或以上的线程在执行过程中,因为争夺资源而造成的一种相互等待的现象,若无外力作用,它们都将无法推进下去;
  • 活锁:处于活锁的实体在不断的改变状态,活锁有可能自行解开;
  • 饥饿:一个或者多个线程因为种种原因无法获得所需的资源,导致一直无法执行的状态。主要原因是:高优先级线程吞噬所有的低优先级线程的CPU时间,线程在等待一个本身也处于等待完成的对象,线程被永久阻塞在一个等待进入同步块的状态。

AQS(AbstractQueuedSynchronizer)

AQS又称队列同步器,是一个用来构建锁或其他同步组件的基础框架。我们常用的比如:ReentrantLock、Semphore、ReentrantReadWriteLock、FutureTask等基础类库都是基于AQS实现的。

原理:
AQS的核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态;如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

AQS对资源的共享方式:

  • Exclusive(独占):只有一个线程能执行,又可以分为公平锁和非公平锁
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch等。

读写锁

ReadWriteLock是一个读写锁接口,读写锁是用来提升并发程序性能的锁分离技术,ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读与读之间不会互斥,读与写,写与读,写与写之间才会互斥,提升了读写的性能。

并发容器

ConcurrentHashMap

为什么要使用ConcurrentHashMap?

因为多线程会导致HashMap的Entry链表形成环型数据结构,一旦形成环型数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。

而使用HashTable因为是对整张表加锁,效率非常低下。

JDK1.7

ConcurrentHashMap 是一个 Segment 数组,Segment是ConcurrentHashMap的静态内部类,它通过继承 ReentrantLock 来进行加锁(分段锁),所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

ConcurrentHashMap 默认有 16 个 Segment,所以最多可以同时支持 16 个线程并发写,这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。

每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。

Segment内部持有一个HashEntry数组(哈希表),并且保证所有对该数组的增删改查方法都是线程安全的。我们通过segmentForHash方法获取分段锁的位置,再根据entryForHash方法获取元素的位置。如果当前元素位置有值,则采用头插法的方式将数据插入链表头部。

扩容操作:

判断是否需要扩容:在插入元素前会先判断Segment里的HashEntry数组是否超过阈值(threshold),如果超过阈值,则对数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断是否已经到达容量的,如果达到了就进行扩容,但是很有可能扩容之后没有新元素再插入,这时HashMap就进行了一次无效的扩容。

如何扩容:在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原来数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment扩容。

JDK1.8

引入了红黑树,同时抛弃了原有的Segment锁,采用Node+CAS+synchronized来保证并发安全性,这样使得锁的粒度更小,并发程度更高。

CopyOnWriteArrayList

设计思想:

  • 读写分离,读和写分开
  • 最终一致性
  • 使用另外开辟空间的思路来解决并发冲突

优缺点:
优点:
当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException。在CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。
缺点:

  • 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致 young gc或者full gc
  • 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个 set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是没法满足实时性要求。
  • 由于实际使用中可能没法保证 CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次 add/set 都要重新复制数组,这个代价实在太高了。在高性能的互联网应用中,这种操作分分钟引起故障。

ThreadLocal

ThreadLocal与线程同步机制不同,线程同步机制是多个线程共享同一变量,而ThreadLocal是为每个线程创建一个单独的变量副本,故而每个线程都可以独立地改变自己所拥有的变量副本,而不会影响其他线程所对应的副本,可以这么说,ThreadLocal为多线程环境下变量问题提供了另外一种解决思路。

ThreadLocal定义了四个方法:

  • get():返回此线程局部变量的当前线程副本中的值;
  • initialValue():返回此线程局部变量的当前线程的“初始值”;
  • remove():移除此线程局部变量的当前线程的值;
  • set(T value):将此线程局部变量的当前线程副本中的值设置为指定值,采用的是开放地址法

ThreadLocal内部还有一个静态内部类ThreadLocalMap,它提供了一种用键值对方式存储每一个线程的变量副本的方法,key为当前ThreadLocal对象,value则是对应线程的变量副本,ThreadLocal定义的四个方法都是对ThreadLocalMap来进行操作,ThreadLocal实例本身并不存储值,它只是提供了一个在当前线程中找到副本值的key。

在这里插入图片描述
ThreadLocal的内存泄漏问题
每个Thread都有一个ThreadLocal.ThreadLocalMap的map,该map的key为ThreadLocal实例,它为一个弱引用,我们知道弱引用有利于GC回收。当ThreadLocal的key == null时,GC就会回收这部分空间,但是value却不一定能够被回收,因为它还与Current Thread存在一个强引用关系,如下图所示:
在这里插入图片描述
由于存在这个强引用关系,会导致value无法回收。如果这个线程对象不会销毁,那么这个强引用关系则会一直存在,就会出现内存泄漏情况。
解决方法:
在ThreadLocalMap中的setEntry()、getEntry(),如果遇到key == null的情况,会对value设置为null。当然我们也可以在使用完ThreadLocal方法后调用remove()方法进行处理。

BlockingQueue

阻塞队列是一个支持两个附加操作的队列,在队列为空时,获取元素的线程会等待队列变为非空,当队列满时,存储元素的线程会等待队列可用。

阻塞队列使用最经典的场景就是 socket 客户端数据的读取和解析,读取数据的线程不断将数据放入队列,然后解析线程不断从队列取数据解析。

线程池

什么是线程池

因为在面向对象编程中,创建和销毁对象很费时,为了提高程序效率,可以利用线程池来减少创建和销毁对象的次数。

线程池就是实现创建若干个可执行的线程放入一个容器中,需要的时候从池中获取线程,使用完毕后放回池中,从而减少了线程的创建和销毁带来的开销。

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

Executors是一个工具类,提供了一些静态工厂方法用于生成一些常用的线程池,如:

  • newSingleThreadExecutor:创建一个单线程的线程池,这个线程池只有一个线程在工作,保证所有任务的执行顺序按照任务的提交顺序执行;

    public static ExecutorService newSingleThreadExecutor() {
            return new FinalizableDelegatedExecutorService
                (new ThreadPoolExecutor(1, 1,
                                        0L, TimeUnit.MILLISECONDS,
                                        new LinkedBlockingQueue<Runnable>()));
        }
  • newFixedThreadPool:创建固定大小的线程池,线程池的大小一旦到达固定值,就会保持不变,适合在服务器上使用(性能好)。

    public static ExecutorService newFixedThreadPool(int nThreads) {
           return new ThreadPoolExecutor(nThreads, nThreads,
                                         0L, TimeUnit.MILLISECONDS,
                                         new LinkedBlockingQueue<Runnable>());
       }
  • newCachedThreadPool:创建一个可缓存的线程池,线程池的线程数量可以随着任务数自行加减,线程池的大小由JVM决定,这里使用的是异步队列SynchronousQueue,而且是非公平的;

    public static ExecutorService newCachedThreadPool() {
            return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                          60L, TimeUnit.SECONDS,
                                          new SynchronousQueue<Runnable>());
        }
  • newScheduledThreadPool:创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。

线程池的优点

  • 降低资源消耗:减少线程创建,销毁带来的开销;
  • 提高响应速度:能够提高系统资源的使用率;
  • 提高线程的可管理性:线程资源很宝贵,如果随意创建线程,会带来一些不好的影响,利用线程池可以进行统一的分配、调度和监控;
  • 附加功能:提供定时执行、单线程、并发数控制等功能。

线程池的主要处理流程

  1. 判断核心线程池是否已满,如果不是,则创建一个新的工作线程来执行任务;
  2. 如果核心线程池满了,判断工作队列是否已经满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里;
  3. 如果工作队列满了,判断线程池是否已满,如果线程池没满,创建线程执行任务;
  4. 如果线程池也满了,就按照饱和策略来处理无法执行的任务。

在这里插入图片描述

线程池的状态

  • RUNNING:正常状态
  • SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务
  • STOP:不接受新的任务提交,也不再处理等待队列中的任务,同时还中断正在执行任务的线程 ;
  • TIDYING:
  • TERMINATED:执行terminated()方法以后

Executor和Executors的区别

  • Executors工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求
  • Executor接口对象能够执行我们的线程任务
  • ExecutorSerivice接口继承了Executor接口并扩展了功能,主要是我们能获得任务执行的状态并且可以获得任务的返回值
  • Future表示异步计算的结果,它提供了检查计算是否完成的方法,以等待计算的完成,可以使用get()方法来获取计算的结果。

线程池中submit()和execute()方法的区别

  • 接收参数:execute只能执行Runnable类型的任务,submit可以执行Runnable和Callable类型的任务;
  • 返回值:submit方法可以返回持有计算结果的Future对象,而execute没有
  • 异常处理:submit更方便异常处理

ThreadPoolExecutor和Executors的区别

《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建,这是因为:

  • newFixedThreadPool 和 newSingleThreadExecutor:
    主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM。
  • newCachedThreadPool 和 newScheduledThreadPool:
    主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。

而ThreaPoolExecutor创建线程池方式只有一种,就是走它的构造函数,参数自己指定,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

ThreadPoolExecutor构造函数的参数分析

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          RejectedExecutionHandler handler) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), handler);
}

核心参数

  • corePoolSize :核心线程数,线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize :线程池中允许存在的工作线程的最大数量;
  • workQueue:当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,任务就会被存放在队列中。
    • ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。
    • LinkedBlockingQueue:一个基于链表结构的无界阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
    • SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
    • PriorityBlockingQueue:一个具有优先级的无届阻塞队列。

其他参数

  • keepAliveTime:线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime才会被回收销毁;
  • unit :keepAliveTime 参数的时间单位。
  • threadFactory:为线程池提供创建新线程的线程工厂;
  • handler :线程池任务队列超过 maxinumPoolSize 之后的拒绝策略。

ThreadPoolExecutor饱和策略

如果当前同时运行的线程数量达到最大线程数量并且队列也已经满了时,就会触发饱和策略,主要有:

  • ThreadPoolExecutor.AbortPolicy:抛出RejectedExecutionException来拒绝新任务的处理。(默认使用)
  • ThreadPoolExecutor.CallerRunsPolicy:通过增加队列容量来不丢弃任何一个任务请求,会降低新任务提交的速度,影响整体性能。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

FutureTask

一个可取消的异步计算,FutureTask提供了对Future的基本实现,可以调用方法去开始或取消一个计算,可以查询计算是否完成并且获取计算结果。

public class FutureTaskDemo {
    public static void main(String[] args) throws InterruptedException, TimeoutException, ExecutionException {
        long startTime = System.currentTimeMillis();

        ExecutorService executorService = Executors.newFixedThreadPool(3);

        FutureTask<String> heatUpWaterFuture = new FutureTask<>(new Callable<String>() {
            @Override
            public String call() throws Exception {
                System.out.println("烧开水");
                TimeUnit.SECONDS.sleep(3);
                System.out.println("烧水用时:"+(System.currentTimeMillis()-startTime)+"ms");
                return "ok";
            }
        });

        FutureTask<String> cookMealsFuture = new FutureTask<>(new Callable<String>() {
            @Override
            public String call() throws Exception {
                System.out.println("煮饭");
                TimeUnit.SECONDS.sleep(5);
                System.out.println("煮饭用时:"+(System.currentTimeMillis()-startTime)+"ms");
                return "ok";
            }
        });

        executorService.submit(heatUpWaterFuture);
        executorService.submit(cookMealsFuture);

        System.out.println("炒菜");

        TimeUnit.SECONDS.sleep(2);
        System.out.println("菜炒好了");

        if(heatUpWaterFuture.get(50,TimeUnit.SECONDS) == "ok"
                && cookMealsFuture.get(50,TimeUnit.SECONDS) == "ok"){
            System.out.println("开饭了");
        }

        long endTime = System.currentTimeMillis();
        System.out.println("做饭用时:"+ (endTime-startTime) + "ms");
        
        System.exit(0);
    }
}

在实际开发过程中,将那些耗时较长,且可以并行的操作都封装成一个FutureTask,该类提供了Future的基本实现,提供了启动和取消计算、查询计算是否完成以及检索计算结果的方法。
FutureTask的实现是基于 AbstractQueuedSynchronizer,FutureTask 声明了一个内部私有的继承于 AQS 的子类 Sync,对 FutureTask 所有公有方法的调用都会委托给这个内部子类。

当FutureTask处于未启动或者已启动的状态时,调用FutureTask对象的get方法将会导致调用线程阻塞,当FutureTask处于已完成的状态时,调用FutureTask的get 方法会立即返回调用结果或者抛出异常。

当FutureTask处于未启动状态时,调用FutureTask对象的cancel方法将导致线程永远不会被执行,当FutureTask处于已启动状态时,调用FutureTask对象cancel(true)方法将以中断执行此任务的线程的方式来试图停止此任务,调用cancel(false)方法将不会对正在进行的任务产生任何影响;当FutureTask处于已完成状态时,调用FutureTask对象的cancel方法将会返回false。

原子操作类atomicXXX的原理

AtomicInteger类主要利用CAS+volatile和native方法来保证原子操作,避免了使用synchronized带来的高开销,执行效率大大提高。
部分源码:

// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
	try {
		valueOffset = unsafe.objectFieldOffset
		(AtomicInteger.class.getDeclaredField("value"));
	} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

CAS的原理是拿期望的值和原本的一个值做比较,如果相同则更新成新的值,UnSafe类的objectFieldOffset()方法是一个本地方法,用来拿到“原来的值”的内存地址,返回值是valueOffset,另外value是一个volatile变量,保证任何时刻线程总能拿到该变量的最新值。

并发工具

CountDownLatch

CountDownLatch与CyclicBarrier都是用于控制并发的工具类,都可以理解成维护的就是一个计数器。

要实现主线程等待所有线程完成,可以使用join()用于让当前执行线程等待join线程执行结束,其实现原理是不停检查join线程是否存活,如果join线程存活,则让当前线程永远等待,join线程终止后,线程的this.notifyAll()方法会在JVM中被调用。

在CountDownLatch类内部定义了一个Sync内部类,这个内部类就是继承自AbstractQueuedSynchronizer的,并且重写了方法tryAcquireSharedtryReleaseShared。例如当调用 awit()方法时,CountDownLatch 会调用内部类Sync 的 acquireSharedInterruptibly() 方法,然后在这个方法中会调用 tryAcquireShared 方法,这个方法就是 CountDownLatch 的内部类 Sync 里重写的 AbstractQueuedSynchronizer 的方法。调用 countDown() 方法同理。

这种方式是使用 AbstractQueuedSynchronizer 的标准化方式,大致分为两步:

1、内部持有继承自 AbstractQueuedSynchronizer 的对象 Sync;

2、并在 Sync 内重写 AbstractQueuedSynchronizer类的protected部分或全部方法,这些方法包括如下几个:

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}

protected boolean tryRelease(int arg) {
    throw new UnsupportedOperationException();
}

protected int tryAcquireShared(int arg) {
    throw new UnsupportedOperationException();
}

protected boolean tryReleaseShared(int arg) {
    throw new UnsupportedOperationException();
}

protected boolean isHeldExclusively() {
    throw new UnsupportedOperationException();
}

之所以要求子类重写这些方法,是为了让使用者(这里的使用者指 CountDownLatch 等)可以在其中加入自己的判断逻辑,例如 CountDownLatch 在 tryAcquireShared中加入了判断,判断 state 是否不为0,如果不为0,才符合调用条件。

tryAcquiretryRelease是对应的,前者是独占模式获取,后者是独占模式释放。

tryAcquireSharedtryReleaseShared是对应的,前者是共享模式获取,后者是共享模式释放。

我们看到 CountDownLatch 重写的方法 tryAcquireShared 实现如下:

protected int tryAcquireShared(int acquires) {
    return (getState() == 0) ? 1 : -1;
}

判断 state 值是否为0,为0 返回1,否则返回 -1。state 值是 AbstractQueuedSynchronizer 类中的一个 volatile 变量。

private volatile int state;

在 CountDownLatch 中这个 state 值就是计数器,在调用 await 方法的时候,将值赋给 state 。

CyclicBarrier

CyclicBarrier让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被拦截的线程才会继续运行,可以用于多线程计算数据,最后合并计算结果的场景。

区别:

  • CountDownLatch一般用于某个线程A等待一个或多个线程执行完任务之后,它才执行;而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;CountDownLatch强调一个线程等待多个线程完成某件事情。CyclicBarrier是多个线程互等,等大家都完成,再携手共进。
  • 调用CountDownLatch的countDown方法后,当前线程并不会阻塞,会继续往下执行;而调用CyclicBarrier的await方法,会阻塞当前线程,直到CyclicBarrier指定的线程全部都到达了指定点的时候,才能继续往下执行;
  • CountDownLatch方法比较少,操作比较简单,而CyclicBarrier提供的方法更多,比如能够通过getNumberWaiting(),isBroken()这些方法获取当前多个线程的状态,并且CyclicBarrier的构造方法可以传入barrierAction,指定当所有线程都到达时执行的业务功能;
  • CountDownLatch是不能复用的(计数器只能使用一次),而CyclicLatch是可以复用的(计数器可以使用reset()方法重置)。

Semaphore

Semaphore 就是一个信号量,它的作用是限制某段代码块的并发数(允许多个线程同时访问),Semaphore有一个构造函数,可以传入一个 int 型整数 n,表示某段代码最多只有 n 个线程可以访问,如果超出了 n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入,通过协调各个线程,以保证合理的使用公共资源,可以用来做流量控制。

Exchanger

Exchanger是一个用于线程间协作的工具类,用于两个线程间交换数据。它提供了一个交换的同步点,在这个同步点两个线程能够交换数据。交换数据是通过exchange方法来实现的,如果一个线程先执行exchange方法,那么它会同步等待另一个线程也执行exchange方法,这个时候两个线程就都达到了同步点,两个线程就可以交换数据。

参考资料

https://tech.meituan.com/2018/11/15/java-lock.html


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

JVM浅析 Previous
Java异常 Next