通过面试题学Java多线程

复习多线程时候做的一些笔记,算是比较全的,考察重点比如Synchronized、RetreenLock、CAS都做了一些介绍,不过这些源码更重要,这篇没给出。
也尝试过只看面试题,但如果不会原理的话,其实还挺难记住的,需要面试题可以看我的另一篇《Java面试题锦集》那篇博客。
也欢迎进入自己创建的博客网站hofe的个人博客,阅读起来会更舒适。这篇博客借鉴了Java3y和JavaGuide的文章,特此感谢,欢迎大家关注他们的公众号,干货多多。

文章目录

一、什么是多线程

1.1 进程与线程

1.1.1 定义

进程
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程

线程
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

1.1.2 区别

在这里插入图片描述
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区DK18之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈。

总结:线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反

线程计数器为什么是私有的
  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了需要注意的是,如果执行的是 native方法,那么程序计数器记录的是 undefined地址,只有执行的是Java代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈为什么是私有的

虚拟机栈:每个Java方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在Java虚拟机栈中入栈和出栈的过程

本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native方法服务。在 HotSpot虚拟机中和Java虚拟机栈合二为所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

共享堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象(所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息常量、静态变量、即时编译器编译后的代码等数据。

1.2 并行与并发

并行:

  • 并行性是指同一时刻内发生两个或多个事件。
  • 并行是在不同实体上的多个事件

并发:

  • 并发性是指同一时间间隔内发生两个或多个事件。
  • 并发是在同一实体上的多个事件

由此可见:并行是针对进程的,并发是针对线程的。

1.3 为什么要使用多线程

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

1.4 使用多线程带来的问题

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁还有受限于硬件和软件的资源闲置问题。

1.5 线程生命周期和状态

在这里插入图片描述
在这里插入图片描述
由上图可以看出:线程创建之后它将处于NEW(新建)状态,调用 start()方法后开始运行,线程这时候处于 READY(可运行)状态。可运行状态的线程获得了CPU时间片( timeslice)后就处于RUNNING(运行)状态。

当线程执行wait()方法之后,线程进入 WAITING(等待)状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIME WAITING(超时等待)状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep( Long millis)方法或wait( Long millis)方法可以将Java线程置于 TIMED WAITING状态。当超时时间到达后java线程将会返回到 RUNNABLE状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞)状态。线程在执行Runnable的run()方法之后将会进入到 TERMINATED(终止)状态。

1.6 上下文切换

概括来说就是:当前任务在执行完CPU时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。

1.7 线程死锁以及避免

学过操作系统的朋友都知道产生死锁必须具备以下四个条件

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系

避免死锁

  1. 破坏互斥条件:这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
  2. 破坏请求与保持条件:一次性申请所有的资源。
  3. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源)释放资源则反序释放。破坏循环等待条件。

1.8 sleep()方法和wait()方法区别和共同点?

两者最主要的区别在于:sleep方法没有释放锁,而wait方法释放了锁

  • 两者都可以暂停线程的执行。
  • wait通常被用于线程间交互/通信, sleep通常被用于暂停执行。
  • wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify或者notifyAll方法。sleep方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout)超时后线程会自动苏醒。

1.9 为什么我们调用 start()方法时会执行run()方法,为什么我们不能直接调用run()方法?

new一个 Thread,线程进入了新建状态;调用start()方法,会启动一个线程并使线程进入了就绪状态当分配到时间片后就可以开始运行了。 start()会执行线程的相应准备工作,然后自动执行run()方法的内容,这是真正的多线程工作。
而直接执行run()方法,会把run方法当成一个main线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结:调用 start方法方可启动线程并使线程进入就绪状态,而run方法只是 thread的一个普通方法调用,还是在主线程里执行。

1.10 多线程实现的三种方式

1.10.1 继承Thread,重写run方法

public class MyThread extends Thread {
    public void run() {
        // ...
    }
}
public static void main(String[] args) {
    MyThread mt = new MyThread();
    mt.start();
}

1.10.2 实现Runnable接口,重写run方法

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // ...
    }
}
public static void main(String[] args) {
    MyRunnable instance = new MyRunnable();
    Thread thread = new Thread(instance);
    thread.start();
}

1.10.3 实现Callable接口,重写run方法

有返回值的任务必须实现Callable接口,类似的,无返回值的任务必须Runnable接口。执行 Callable任务后,可以获取一个Future的对象,在该对象上调用get就可以获取到Callable任务 返回的Object了,再结合线程池接口ExecutorService就可以实现传说中有返回结果的多线程 了

Callable 可以有返回值,返回值通过 FutureTask 进行封装

public class MyCallable implements Callable<Integer> {
    public Integer call() {
        return 123;
    }
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
    MyCallable mc = new MyCallable();
    FutureTask<Integer> ft = new FutureTask<>(mc);
    Thread thread = new Thread(ft);
    thread.start();
    System.out.println(ft.get());
}

1.10.4 实现接口 与继承 Thread区别

实现接口会更好一些,因为:

  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大。

二、线程池

线程池可以看做是线程的集合。线程生命周期的开销非常高,创建和销毁线程所花费的时间和资源可能比处理客户端的任务花费的时间和资源更多,并且还会有某些空闲线程也会占用资源。引入线程池,当请求到来时,线程池给这个请求分配一个空闲的线程,任务完成后回到线程池中等待下次任务(而不是销毁)。这样就实现了线程的重用。

所以说:线程最好是交由线程池来管理,这样可以减少对线程生命周期的管理,一定程度上提高性能。
在这里插入图片描述

2.1 线程池分类

2.1.1 通过构造方法实现

在这里插入图片描述

2.1.2 通过Excutor工具类

JDK给我们提供了Excutor框架来使用线程池,它是线程池的基础。但是严格意义上讲 Executor 并不是一个线程池,而 只是一个执行线程的工具。真正的线程池接口是ExecutorService
在这里插入图片描述
在这里插入图片描述

newFixedThreadPool

该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
	            0L, TimeUnit.MILLISECONDS,
	            new LinkedBlockingQueue<Runnable>());
}
newCachedThreadPool

该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
             60L, TimeUnit.SECONDS,
             new SynchronousQueue<Runnable>());
}
newSingleThreadExecutor

方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
             0L, TimeUnit.MILLISECONDS,
             new LinkedBlockingQueue<Runnable>()));
}
弊端

在这里插入图片描述

2.2 ThreadPoolExecutor详解

Executor调用创建的三种线程池内部也是通过传递ThreadPoolExecutor不同参数实现的。

2.2.1 构造参数

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }
  • 核心线程数量:定义了最小可以同时运行的线程数量
  • 最大线程数量:当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • 允许线程空闲时间:当线程池中的线程数量大于 corePoo lsize的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁
  • 时间对象:keepAliveTime参数的时间单位
  • 阻塞队列:当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中
  • 线程工厂:创建新线程的时候会用到
  • 任务拒绝策略:

在这里插入图片描述

2.2.2 参数要点

线程数量要点:

  • 如果运行线程的数量少于核心线程数量,则创建新的线程处理请求
  • 如果运行线程的数量大于核心线程数量,小于最大线程数量,则当队列满的时候才创建新的线程
  • 如果核心线程数量等于最大线程数量,那么将创建固定大小的连接池
  • 如果设置了最大线程数量为无穷,那么允许线程池适合任意的并发数量

线程空闲时间要点:

  • 当前线程数大于核心线程数,如果空闲时间已经超过了,那该线程会销毁。
    排队策略要点:

  • 同步移交:不会放到队列中,而是等待线程执行它。如果当前线程没有执行,很可能会新开一个线程执行。

  • 无界限策略:如果核心线程都在工作,该线程会放到队列中。所以线程数不会超过核心线程数

  • 有界限策略:可以避免资源耗尽,但是一定程度上减低了吞吐量

当线程关闭或者线程数量满了和队列饱和了,就有拒绝任务的情况了:

拒绝任务策略:

  • 直接抛出异常
  • 使用调用者的线程来处理
  • 直接丢掉这个任务
  • 丢掉最老的任务

2.3 execute执行方法

在这之前,先了解下Executor与Callable

2.3.1 实现 Runnable接口和 Callable接口的区别

Runnable自Java1.0以来一直存在,但 Callable仅在Java1.5中引入目的就是为了来处理 Runnable不支持的用例。
Runnable接口不会返回结果或抛出检查异常,但是 Callable接口可以。
所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable接口,这样代码看起来会更加简洁。

工具类 Executors可以实现 Runnable对象和 Callable对象之间的相互转换。Executors callable( Runnable task ) 或 Executors, callable(Runnable task, object resule))

2.3.2 execute()和submit方法区别

submit方法
在这里插入图片描述
execute()方法

public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        int c = ctl.get();
    //如果线程池中运行的线程数量<corePoolSize,则创建新线程来处理请求,即使其他辅助线程是空闲的。
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }//如果线程池中运行的线程数量>=corePoolSize,且线程池处于RUNNING状态,且把提交的任务成功放入阻塞队列中,就再次检查线程池的状态,
      // 1.如果线程池不是RUNNING状态,且成功从阻塞队列中删除任务,则该任务由当前 RejectedExecutionHandler 处理。
      // 2.否则如果线程池中运行的线程数量为0,则通过addWorker(null, false)尝试新建一个线程,新建线程对应的任务为null。
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
    // 如果以上两种case不成立,即没能将任务成功放入阻塞队列中,且addWoker新建线程失败,则该任务由当前 RejectedExecutionHandler 处理。
        else if (!addWorker(command, false))
            reject(command);
    }

三、Java锁

3.1 锁的分类

3.1.1 同步锁与死锁

同步锁
当多个线程同时访问同一个数据时,很容易出现问题。为了避免这种情况出现,我们要保证线程 同步互斥,就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。 Java 中可 以使用synchronized关键字来取得一个对象的同步锁。
死锁
何为死锁,就是多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放

3.1.2 乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为 别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数 据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新), 如果失败则要重复读-比较-写的操作。
java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入 值是否一样,一样则更新,否则失败。

3.1.3 悲观锁

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人 会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block(阻塞)直到拿到锁。 java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到, 才会转换为悲观锁,如RetreenLock


java并发包提供的加锁模式分为独占锁和共享锁。

3.1.4 独占锁

独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock 就是以独占方式实现的互斥锁。 独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线 程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。

3.1.5 共享锁

共享锁则允许多个线程同时获取锁,并发访问 共享资源,如:ReadWriteLock。共享锁则是一种 乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源

  1. AQS的内部类Node定义了两个常量SHARED和EXCLUSIVE,他们分别标识 AQS队列中等 待线程的锁获取模式。
  2. java的并发包中提供了ReadWriteLock,读-写锁。它允许一个资源可以被多个读操作访问, 或者被一个 写操作访问,但两者不能同时进行。

3.1.6 公平锁与非公平锁

公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁, ReentrantLock在构造函数中提供了是否公平锁的初始化方式来定义公平锁。

JVM 按随机、就近原则分配锁的机制则称为不公平锁,ReentrantLock 在构造函数中提供了 是否公平锁的初始化方式,默认为非公平锁。非公平锁实际执行的效率要远远超出公平锁,除非 程序有特殊需要,否则常用非公平锁的分配机制。


3.1.7 可重入锁

本文里面讲的是广义上的可重入锁,而不是单指JAVA下的ReentrantLock。可重入锁,也叫 做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受 影响。在JAVA环境下 ReentrantLock 和synchronized 都是 可重入锁。

3.1.8 分段锁

分段锁也并非一种实际的锁,而是一种思想ConcurrentHashMap是学习分段锁的好实践

3.2 锁的实现

3.2.1 Volatile关键字

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他 线程。volatile 变量具备两种特性,volatile变量不会被缓存在寄存器或者对其他处理器不可见的 地方,因此在读取volatile类型的变量时总会返回新写入的值。

可见性
其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的 值对于其他线程是可以立即获取的。

禁止重排序
volatile 禁止了指令重排。

比sychronized关键字更轻量级的同步机制
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一 种比sychronized关键字更轻量级的同步机制。volatile适合这种场景:一个变量被多个线程共 享,线程直接给这个变量赋值。

当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有 多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

适用场景
值得说明的是对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量, 但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。在某些场景下可以 代替Synchronized。但是,volatile的不能完全取代Synchronized的位置,只有在一些特殊的场景下,才能适用volatile。
总的来说,必须同时满足下面两个条件才能保证在并发环境的线程安 全:

  1. 对变量的写操作不依赖于当前值(比如 i++),或者说是单纯的变量赋值(boolean flag = true)。
  2. 该变量没有包含在具有其他变量的不变式中,也就是说,不同的volatile变量之间,不 能互相依赖。只有在状态真正独立于程序内其他内容时才能使用 volatile

与Synchronized区别
在这里插入图片描述

3.2.2 Synchronized

Synchronized属于独占式的悲观锁,同时属于可重 入锁
作用范围

  1. 作用于方法时,锁住的是对象的实例(this);
  2. 当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen (jdk1.8 则是 metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁, 会锁所有调用该方法的线程;
  3. synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有多个队列, 当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

核心组件

  1. Wait Set:调用wait方法被阻塞的线程被放置在这里;
  2. Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
  3. Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
  4. OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
  5. Owner:当前已经获取到所有资源的线程被称为Owner;
  6. !Owner:当前释放锁的线程。

实现
在这里插入图片描述

  1. JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下, ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将 一部分线程移动到EntryList中作为候选竞争线程。
  2. Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定 EntryList中的某个线程为OnDeck线程(一般是先进去的那个线程)。
  3. Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck, OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在 JVM中,也把这种选择行为称之为“竞争切换”。
  4. OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList 中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify 或者notifyAll唤醒,会重新进去EntryList中。
  5. 处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统 来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
  6. Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先 尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是 不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁 资源。 参考:https://blog.csdn.net/zqz_zqz/article/details/70233767
  7. 每个对象都有个 monitor 对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加 上monitorenter和monitorexit指令来实现的,方法加锁是通过一个标记位来判断的
  8. synchronized 是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线 程加锁消耗的时间比有用操作消耗的时间更多。
  9. Java1.6,synchronized进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向 锁等,效率有了本质上的提高。在之后推出的 Java1.7 与 1.8 中,均对该关键字的实现机理做 了优化。引入了偏向锁和轻量级锁。都是在对象头中有标记位,不需要经过操作系统加锁。
  10. 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀;

双重检验所锁实现单例模式在这里插入图片描述

3.2.3 RetreenLock

ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完 成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁避免多线程死锁的方法。

Lock主要方法

  1. void lock(): 执行此方法时, 如果锁处于空闲状态, 当前线程将获取到锁. 相反, 如果锁已经 被其他线程持有, 将禁用当前线程, 直到当前线程获取到锁.
  2. boolean tryLock():如果锁可用, 则获取锁, 并立即返回 true, 否则返回 false. 该方法和 lock()的区别在于, tryLock()只是"试图"获取锁, 如果锁不可用, 不会导致当前线程被禁用, 当前线程仍然继续往下执行代码. 而 lock()方法则是一定要获取到锁, 如果锁不可用, 就一 直等待, 在未获得锁之前,当前线程并不继续向下执行.
  3. void unlock():执行此方法时, 当前线程将释放持有的锁. 锁只能由持有者释放, 如果线程 并不持有锁, 却执行该方法, 可能导致异常的发生.
  4. Condition newCondition():条件对象,获取等待通知组件。该组件和当前的锁绑定, 当前线程只有获取了锁,才能调用该组件的 await()方法,而调用后,当前线程将缩放锁。
  5. getHoldCount() :查询当前线程保持此锁的次数,也就是执行此线程执行lock方法的次 数。
  6. getQueueLength():返回正等待获取此锁的线程估计数,比如启动 10 个线程,1 个 线程获得锁,此时返回的是9
  7. getWaitQueueLength:(Condition condition)返回等待与此锁相关的给定条件的线 程估计数。比如 10 个线程,用同一个 condition 对象,并且此时这 10 个线程都执行了 condition对象的 await方法,那么此时执行此方法返回10
  8. hasWaiters(Condition condition):查询是否有线程等待与此锁有关的给定条件 (condition),对于指定contidion对象,有多少线程执行了condition.await方法
  9. hasQueuedThread(Thread thread):查询给定线程是否等待获取此锁
  10. hasQueuedThreads():是否有线程等待此锁
  11. isFair():该锁是否公平锁
  12. isHeldByCurrentThread(): 当前线程是否保持锁锁定,线程的执行 lock 方法的前后分 别是false和true
  13. isLock():此锁是否有任意线程占用
  14. lockInterruptibly():如果当前线程未被中断,获取锁
  15. tryLock():尝试获得锁,仅在调用时锁未被线程占用,获得锁
  16. tryLock(long timeout TimeUnit unit):如果锁在给定等待时间内没有被另一个线程保持, 则获取该锁

ReentrantLock实现

 public class MyService { 
            private Lock lock = new ReentrantLock(); 
 //Lock lock=new ReentrantLock(true);//公平锁 
            //Lock lock=new ReentrantLock(false);//非公平锁 
           private Condition condition=lock.newCondition();//创建Condition 
    public void testMethod() { 
             try { 
               lock.lock();//lock加锁 
  //1:wait 方法等待: 
            //System.out.println("开始wait"); 
            condition.await(); 
//通过创建Condition对象来使线程wait,必须先执行lock.lock方法获得锁 
 //:2:signal方法唤醒 
 condition.signal();//condition对象的signal方法可以唤醒wait 线程 
            for (int i = 0; i < 5; i++) { 
 System.out.println("ThreadName=" + Thread.currentThread().getName()+ (" " + (i + 1))); 
            } 
        } catch (InterruptedException e) { 
             e.printStackTrace(); 
        } 
        finally
        { 
            lock.unlock(); 
        } 
    } 
} 

Condition类与Object类区别

  1. Condition类的awiat方法和Object类的wait方法等效
  2. Condition类的signal方法和Object类的notify方法等效
  3. Condition类的signalAll方法和Object类的notifyAll方法等效
  4. ReentrantLock类可以唤醒指定条件的线程,而object的唤醒是随机的

tryLock与 lock 和 lockInterruptibly区别

  1. tryLock能获得锁就返回true,不能就立即返回false,tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回false
  2. lock能获得锁就返回true,不能的话一直等待获得锁
  3. lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程, lock不会抛出异常,而lockInterruptibly会抛出异常

3.2.4 ReentrantLock与synchronized区别

  1. ReentrantLock 通过方法lock()与unlock()来进行加锁与解锁操作,与synchronized会 被 JVM 自动解锁机制不同,ReentrantLock 加锁后需要手动进行解锁。为了避免程序出 现异常而无法正常解锁的情况,使用 ReentrantLock 必须在 finally 控制块中进行解锁操 作。
  2. ReentrantLock相比synchronized的优势是可中断、公平锁、多个锁。这种情况下需要 使用ReentrantLock。
    在这里插入图片描述

3.2.5 ReentrantReadWriteLock

为了提高性能,Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如 果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写 锁,多个读锁不互斥,读锁与写锁互斥,这是由jvm自己控制的,你只要上好相应的锁即可。
读锁
如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁

写锁
如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。

总之,读的时候上 读锁,写的时候上写锁!
Java 中读写锁有个接口 java.util.concurrent.locks.ReadWriteLock ,也有具体的实现 ReentrantReadWriteLock

3.3 锁优化

这里的锁优化主要是指 JVM 对 synchronized 的优化。

3.3.1 减少锁持有时间

只用在有线程安全要求的程序上加锁

3.3.2 减小锁粒度

将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。 降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。典型的减小锁粒度的案例就是 ConcurrentHashMap。

3.3.3 锁分离

常见的锁分离就是读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互 斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能,具体也请查看[高并发Java 五] JDK并发包1。读写分离思想可以延伸,只要操作互不影响,锁就可以分离。比如 LinkedBlockingQueue 从头部取出,从尾部放数据

3.3.4 自旋锁

互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。

自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。

在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

3.3.5 锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

3.3.6 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

3.4 四种锁状态

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。

3.4.1 重量级锁

Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又 是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用 户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。 JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和 “偏向锁”。

3.4.2 轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,它使用 CAS 操作来避免重量级锁使用互斥量的开销。但是,首先需要强调一点的是, 轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量 级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场 景是线程交替执行同步块的情况(整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步),如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀 为重量级锁( CAS 失败了再改用互斥量进行同步)。

3.4.3 偏向锁

偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起 来让这个线程得到了偏护。这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。轻 量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进 一步提高性能。

3.4.4 无锁

四、CAS(比较并交换)

比较并交换(compare and swap, CAS),是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。

它包含 3 个参数 CAS(V,E,N)。V 表示要更新的变量(内存值),E 表示预期值(旧的),N 表示新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N;如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当 前线程什么都不做。最后,CAS返回当前V的真实值。

CAS 操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。当多个线程同时 使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理, CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

4.1 CAS失败的两种情况

失败放弃
在这里插入图片描述

失败自旋(循环再试)
在这里插入图片描述
在这里插入图片描述

4.2 原子包Atomic(CAS的应用)

原子变量类在java.util.concurrent.atomic包下
在这里插入图片描述
我们可以对其进行分类:

  • 基本类型:
    • AtomicBoolean:布尔型
    • AtomicInteger:整型
    • AtomicLong:长整型
  • 数组:
    • AtomicIntegerArray:数组里的整型
    • AtomicLongArray:数组里的长整型
    • AtomicReferenceArray:数组里的引用类型
  • 引用类型:
    • AtomicReference:引用类型
    • AtomicStampedReference:带有版本号的引用类型
    • AtomicMarkableReference:带有标记位的引用类型
  • 对象的属性:
    • AtomicIntegerFieldUpdater:对象的属性是整型
    • AtomicLongFieldUpdater:对象的属性是长整型
    • AtomicReferenceFieldUpdater:对象的属性是引用类型

JDK8新增DoubleAccumulator、LongAccumulator、DoubleAdder、LongAdder是对AtomicLong等类的改进。比如LongAccumulator与LongAdder在高并发环境下比AtomicLong更高效。

AtomicInteger实例

class Count{// 共享变量(使用AtomicInteger来替代Synchronized锁)
    private AtomicInteger count = new AtomicInteger(0);
    
    public Integer getCount() {
        return count.get();
    }
    public void increase() {
        count.incrementAndGet();
    }
}

Atomic包里的类基本都是使用Unsafe实现的包装类。
Unsafe里边有几个我们喜欢的方法(CAS):

// 第一和第二个参数代表对象的实例以及地址,第三个参数代表期望值,第四个参数代表更新值
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);

从原理上概述就是:Atomic包的类的实现绝大调用Unsafe的方法,而Unsafe底层实际上是调用C代码,C代码调用汇编,最后生成出一条CPU指令cmpxchg,完成操作。这也就为啥CAS是原子性的,因为它是一条CPU指令,不会被打断

compareAndSwapInt实例

public class AtomicInteger extends Number implements java.io.Serializable {   
        private volatile int value;   
public final int get() {   
           return value;   
     }   
     public final int getAndIncrement() {   
        for (;;) {  //CAS自旋,一直尝试,直达成功 
           int current = get();   
            int next = current + 1;   
            if (compareAndSet(current, next))   
                return current;   
        }   
    }   
    public final boolean compareAndSet(int expect, int update) {   
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);   
    }   
} 

4.3 ABA问题

CAS 会导致“ABA 问题”。CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时 刻比较并替换,那么在这个时间差类会导致数据的变化。

描述
比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操 作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过 程就是没有问题的。
在这里插入图片描述

解决
部分乐观锁的实现是通过版本号(version)的方式来解决ABA问题,乐观锁每次在执行数据的修 改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本 号执行+1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问 题,因为版本号只会增加不会减少。

五、AQS(抽象队列同步器)

AbstractQueuedSynchronizer类如其名,抽象的队列式的同步器,AQS定义了一套多线程访问 共享资源的同步器框架,许多同步类实现都依赖于它,Lock的子类实现都是基于AQS的。如常用的 ReentrantLock/Semaphore/CountDownLatch。
在这里插入图片描述

5.1 原理

https://user-gold-cdn.xitu.io/2018/4/25/162fcd34dd621e32?w=1864&h=4179&f=png&s=799799
通读了一遍,可以总结出以下比较关键的信息:

  • AQS其实就是一个可以给我们实现锁的框架
  • 内部实现的关键是:先进先出的队列、state状态
  • 定义了内部类ConditionObject
  • 拥有两种线程模式
    • 独占模式
    • 共享模式
  • 在LOCK包中的相关锁(常用的有ReentrantLock、ReadWriteLock)都是基于AQS来构建
  • 一般我们叫AQS为同步器

六、同步工具类

Java为我们提供了三个同步工具类:

  • CountDownLatch(闭锁、线程计数器)
  • CyclicBarrier(栅栏)
  • Semaphore(信号量)
    这几个工具类是为了能够更好控制线程之间的通讯问题

6.1 CountDownLatch

CountDownLatch是一个同步的辅助类,允许一个或多个线程一直等待,直到其它线程完成它们的操作。(任务A需要等待其它4个任务完成才能执行)
在这里插入图片描述
它常用的API其实就两个:await()和countDown()
用法

public class Test {public static void main(String[] args) {final CountDownLatch countDownLatch = new CountDownLatch(5);// 3y线程启动
        new Thread(new Runnable() {
            @Override
            public void run() {
           
                try {
                    // 这里调用的是await()不是wait()
                    countDownLatch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("等待其它线程执行完毕才开始");
            }
        }).start();// 其他线程启动
        for (int i = 0; i < 5; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println("其它线程执行完毕");
                    countDownLatch.countDown();
                }
            }).start();
        }
    }
}

6.2 CyclicBarrier

CyclicBarrier允许一组线程互相等待,直到到达某个公共屏障点。叫做cyclic是因为当所有等待线程都被释放以后,CyclicBarrier可以被重用(对比于CountDownLatch是不能重用的)

用法
CountDownLatch注重的是等待其他线程完成,CyclicBarrier注重的是:当线程到达某个状态后,暂停下来等待其他线程,所有线程均到达以后,继续执行。

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;public class Test {public static void main(String[] args) {final CyclicBarrier CyclicBarrier = new CyclicBarrier(2);
        for (int i = 0; i < 2; i++) {new Thread(() -> {
​
                String name = Thread.currentThread().getName();
                if (name.equals("Thread-0")) {
                    name = "线程0";
                } else {
                    name = "线程1";
                }
                System.out.println(name + "等待执行");
                try {// 两个人都要到体育西才能发朋友圈
                    CyclicBarrier.await();
                    // 他俩到达了体育西,看见了对方发了一条朋友圈:
                    System.out.println(name+"可以开始执行");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

6.3 Semaphore

Semaphore(信号量)实际上就是可以控制同时访问的线程个数,它维护了一组"许可证"。

  • 当调用acquire()方法时,会消费一个许可证。如果没有许可证了,会阻塞起来
  • 当调用release()方法时,会添加一个许可证。

这些"许可证"的个数其实就是一个count变量

用法

public class Test {public static void main(String[] args) {// 假设有50个同时来到酸奶店门口
        int nums = 50;// 酸奶店只能容纳10个人同时挑选酸奶
        Semaphore semaphore = new Semaphore(10);for (int i = 0; i < nums; i++) {
            int finalI = i;
            new Thread(() -> {
                try {
                    // 有"号"的才能进酸奶店挑选购买
                    semaphore.acquire();
​
                    System.out.println("顾客" + finalI + "在挑选商品,购买...");// 假设挑选了xx长时间,购买了
                    Thread.sleep(1000);// 归还一个许可,后边的就可以进来购买了
                    System.out.println("顾客" + finalI + "购买完毕了...");
                    semaphore.release();
​
​
​
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();}}
}

6.4 总结

Java为我们提供了三个同步工具类:

  • CountDownLatch(闭锁)某个线程等待其他线程执行完毕后,它才执行(其他线程等待某个线程执行完毕后,它才执行)
  • CyclicBarrier(栅栏)一组线程互相等待至某个状态,这组线程再同时执行。
  • Semaphore(信号量)控制一组线程同时执行。

七、ThreadLocal

ThreadLocal 提供了线程的局部变量,每个线程都可以通过set()和get()来对这个局部变量进行操作,但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离。

ThreadLocal设计的目的是为了能够在当前线程中有属于自己的变量,并不是为了解决并发或者共享变量的问题

7.1 实现原理

Set和Get方法

	public void set(T value) {// 得到当前线程对象
        Thread t = Thread.currentThread();
    
    // 这里获取ThreadLocalMap
        ThreadLocalMap map = getMap(t);// 如果map存在,则将当前线程对象t作为key,要存储的对象作为value存到map里面去
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

通过currentThread()方法可以获得Thread对象t,通过getMap(t)就可以获得当前的ThreadLocalMap对象

static class ThreadLocalMap {static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    //....很长
}

ThreadLocalMap是ThreadLocal的一个内部类。用Entry类来进行存储。我们的值都是存储到这个Map上的,key是当前ThreadLocal对象,而value为要存储的对象!
在这里插入图片描述

7.2 内存泄漏问题

ThreadLocal的对象关系引用图:
在这里插入图片描述

这里是引用

另一种说法:
ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。

想要避免内存泄露就要手动remove()

7.3 总结

  1. 每个Thread维护着一个ThreadLocalMap的引用
  2. ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储
  3. 调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值是传递进来的对象
  4. 调用ThreadLocal的get()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象
  5. ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。

正因为这个原理,所以ThreadLocal能够实现“数据隔离”,获取当前线程的局部变量值,不受其他线程影响。

原创文章 22 获赞 10 访问量 2322

猜你喜欢

转载自blog.csdn.net/qq_41011723/article/details/105761802