Java并发进阶笔记

160 阅读14分钟

synchronized

synchronized 关键字

synchronized 关键字保证被修饰的方法或代码块任意时刻只能有一个线程执行。
java6 后,官方从 JVM 层面对 synchronized 较大优化。对锁的实现引入大量优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

怎么使用 synchronized 关键字

最主要的三种使用方式:

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
  • 修饰静态方法:给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static 表明这是该类的一个静态资源,不管 new 多少个对象,只有一份)。如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的是当前类的锁,访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结:synchronized 关键字加到 static 静态方法和synchronized(class)代码块上都是给Class类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用synchronized(String a),因为 JVM 中,字符串常量池具有缓存功能。

双重校验锁实现对象单例(线程安全)

synchronized 关键字的具体使用

/**
 * 双重校验锁实现对象单例(线程安全)
 * @author Shenyf
 * @date 2019/11/14 10:58
 */
public class Singleton {

    private volatile static Singleton uniqueInstance;

    private Singleton() {}

    public static Singleton getUniqueInstance() {
        //判断对象是否实例过,没有实例化过才进入加锁代码
        if (null == uniqueInstance) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (null == uniqueInstance) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }

}

uniqueInstance 采用 volatile 关键字修饰,因为uniqueInstance = new Singleton();这段代码分为三步执行:

  1. 为 uniqueInstance 分配内存空间。
  2. 初始化 uniqueInstance。
  3. 将 uniqueInstance 指向分配的内存地址。

由于 JVM 指令重排的特性,执行顺序可能变为 1->3->2。在单线程环境下不会出现问题,但在多线程环境下会导致一个线程获得还未初始化的实例。如:线程 T1 执行 1 和 3,此时 T2 调用getUniqueInstance()后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
使用 volatile 可以禁止指令重排,保证在多线程环境下也能正常运行。

synchronized 关键字底层原理

synchronized 关键字底层属于 JVM层面

  1. synchronized 同步语句块的情况:
    synchronized 同步语句块的实现使用 monitorenter 和 monitorexit 指令,monitorenter 指令指向同步代码块的开始位置,monitorexit指令指向同步代码块的结束位置。执行 monitorenter 指令时,线程试图获取锁,也就是获取 monitor(monitor 对象存在于每个 Java 对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也就是 Java 中任意对象可以作为锁的原因)的持有权。当计数器为0则成功获取,获取后将锁计数器设为1(加1)。执行 monitorexit 之后,将锁计数器设为0,表明锁被释放。若获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放。
  2. synchronized 修饰方法的情况:
    synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标识来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

JDK1.6 后的 synchronized 关键字底层优化

JDK1.6 对锁的实现引入大量优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等来减少锁操作的开销。
锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,会随着竞争的激烈而逐渐升级。锁只可以升级不可以降级,这种策略为了提高获得锁和释放锁的效率。

synchronized 和 ReentrantLock 的区别

  1. 两者都是可重入锁
    可重入锁:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
  2. synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
    synchronized 依赖于 JVM 实现,JDK1.6 为synchronized 关键字进行很多优化,这些优化都在虚拟机层面实现。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要lock()unlock()方法配合 try/finally 语句块来完成)。
  3. ReentrantLock 比 synchronized 增加了一些高级功能
    主要有三点:① 等待可中断;② 可实现公平锁 ③ 可实现选择性通知(锁可以绑定多个条件)
  • ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()实现。
  • ReentrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。公平锁:先等待的线程先获得锁。ReentrantLock 默认情况是非公平,可以通过 ReentrantLock 类的ReentrantLock(boolean fair)构造方法来制定是否公平。
  1. 性能已不是选择标准

volatile 关键字

Java内存模型

在当前Java内存模型下,线程可以把变量保存本地内存(如机器的寄存器)中,而不是直接在主存中进行读写。 这可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致
把变量声明为 volatile,指示 JVM 该变量不稳定,每次使用它都到主存中进行读取。所以 volatile 主要作用就是保证变量的可见性,还有一个作用是防止指令重排序。

synchronized 关键字和 volatile 关键字的区别

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能比 synchronized 要好。但是 volatile 关键字只能用于变量而 synchronized 可以修饰方法以及代码块。 synchronized 关键字在 JDK1.6 后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景更多一些。
  • 多线程访问 volatile 关键字不会发生阻塞, 而 synchronized 关键字可能会发生阻塞。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。 synchronized 关键字两者都能保证,
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ThreadLocal

ThreadLocal 简介

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己专属本地变量,使用JDK 提供的 ThreadLocal 类。
ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可将 ThreadLocal 类比喻成存放数据的盒子,盒子可以存储每个线程的私有数据。
如果创建一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。它们可以使用get()set()方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

ThreadLocal 示例

import java.text.SimpleDateFormat;
import java.util.Random;

/**
 * ThreadLocal示例
 * @author Shenyf
 * @date 2019/11/15 11:15
 */
public class ThreadLocalExample implements Runnable{

    //SimpleDateFormat非线程安全,所以每个线程要有自己独立的副本
    private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));


    public static void main(String[] args) throws InterruptedException {
        ThreadLocalExample obj = new ThreadLocalExample();
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(obj, "" + i);
            Thread.sleep(new Random().nextInt(1000));
            t.start();
        }
    }

    @Override
    public void run() {
        System.out.println("Thread Name= " + Thread.currentThread().getName() + " default Formatter " + formatter.get().toPattern());
        try {
            Thread.sleep(new Random().nextInt(1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //formatter pattern is changed here by thread, but it won't reflect to other threads
        formatter.set(new SimpleDateFormat());

        System.out.println("Thread Name= " + Thread.currentThread().getName() + " formatter= " + formatter.get().toPattern());
    }

}

Output:

Thread Name= 0 default Formatter yyyyMMdd HHmm
Thread Name= 1 default Formatter yyyyMMdd HHmm
Thread Name= 2 default Formatter yyyyMMdd HHmm
Thread Name= 2 formatter= yy-M-d ah:mm
Thread Name= 0 formatter= yy-M-d ah:mm
Thread Name= 1 formatter= yy-M-d ah:mm
Thread Name= 3 default Formatter yyyyMMdd HHmm
Thread Name= 3 formatter= yy-M-d ah:mm
Thread Name= 4 default Formatter yyyyMMdd HHmm
Thread Name= 5 default Formatter yyyyMMdd HHmm
Thread Name= 4 formatter= yy-M-d ah:mm
Thread Name= 6 default Formatter yyyyMMdd HHmm
Thread Name= 5 formatter= yy-M-d ah:mm
Thread Name= 7 default Formatter yyyyMMdd HHmm
Thread Name= 6 formatter= yy-M-d ah:mm
Thread Name= 7 formatter= yy-M-d ah:mm
Thread Name= 8 default Formatter yyyyMMdd HHmm
Thread Name= 9 default Formatter yyyyMMdd HHmm
Thread Name= 8 formatter= yy-M-d ah:mm
Thread Name= 9 formatter= yy-M-d ah:mm

从输出中看出,Thread-0 已经改变了 formatter 的值,但 thread-3 默认格式化程序与初始化值相同,其它线程也一样。

ThreadLocal 原理

从 Thread 类源码分析。

    public class Thread implements Runnable {
        ······
        //与此线程有关的ThreadLocal值。由ThreadLocal类维护
        ThreadLocal.ThreadLocalMap threadLocals = null;

        //与此线程相关的InheritableThreadLocal值。由InheritableThreadLocal类维护
        ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
        ······
}

Thread 类中有一个 threadLocals 和 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量。ThreadLocalMap 可以理解为 ThreadLocal 类实现的定制化 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的get()set()时才创建它们,实际上调用这两个方法时,调用的是 ThreadLocalMap 类对应的get()set()

ThreadLocal 类的set()方法

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

最终的变量放在当前线程的 ThreadLocalMap 中,并不是放在 ThreadLocal 上,ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。 ThreadLocal 类中可以通过 Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的 ThreadLocalMap 对象。
每个 Thread 中都具备一个 ThreadLocalMap,而 ThreadLocalMap 可以存储以 ThreadLocal 为 key 的键值对。如果在同一个线程中声明了两个 ThreadLocal对象,而在 Thread 内部都是使用仅有那个 ThreadLocalMap 存放数据,ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是 ThreadLocal 对象调用set()设置的值。ThreadLocal 是 Map 结构是为了让每个线程可以关联多个 ThreadLocal 变量。这也就解释了 ThreadLocal 声明的变量为什么在每一个线程都有自己的专属本地变量。

PS:ThreadLocalMap 是 ThreadLocal 的静态内部类。

ThreadLocal 内存泄露问题

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清除,而 value 不会。这样 ThreadLocalMap 中会出现 key 为 null 的 Entry。如果不做任何操作,value 永远无法被 GC 回收,可能会产生内存泄露。 ThreadLocalMap 实现中,在调用set()get()remove()时,会清理 key 为 null 的记录。使用完 ThreadLocal 后,最好手动调用remove()

线程池

线程池的作用

线程池提供了一种限制和管理资源(包括执行一个任务)。每个线程池还维护一些基本统计信息,例如已完成任务的数量。
使用线程池的好处:

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,会消耗系统资源,还会降低系统的稳定性。使用线程池可以进行统一的分配,调优和监控。

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

Callable 目的是为了处理 Runnable 不支持的用例。Runnable 接口不会返回结果或抛出异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐用 Runnable 接口,这也代码会更加简洁。
工具类 Executor 可以实现 Runnable 对象和 Callable 对象之间的相互转换。

执行 execute() 和 submit() 的区别

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

如何创建线程池

《阿里巴巴Java开发手册》强制线程池不允许用 Executors 去创建,而是通过 ThreadPoolExecutor。这样是为了让编写者更加明确线程池的运行规则,规避资源耗尽的风险。
Executors 返回线程池对象的弊端:

  • FixedThreadPool 和 SingleThreadExecutor:允许请求的队列长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool 和 ScheduledThreadPool:允许创建的线程数量为 Integer.MAX_VALUE,可能会创建大量线程,从而导致 OOM。

创建线程池的方式:
方法一:通过构造方法实现

方法二:通过 Executor 框架的工具类 Executors 来实现,可以创建三种类型的 ThreadPoolExecutor:

  • FixedThreadLocal:返回一个固定线程数的线程池。当有一个新的任务提交,线程池若有空闲线程,立即执行。若没有,任务被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor:返回一个只有一个线程的线程池。若多出一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool:返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
    对应Executors工具类中的方法

ThreadPoolExecutor 类分析

ThreadPoolExecutor 类中提供四个构造方法。这里分析最长的一个,其余三个都是在该构造方法的基础上产生。

/**
 * 用给定的初始参数创建一个新的ThreadPoolExecutor
 */
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.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

ThreadPoolExecutor 构造函数重要参数分析

ThreadPoolExecutor 3个最重要的参数:

  • corePoolSize:核心线程数,线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize:当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数量。
  • workQueue:当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到,新任务就会被存放在队列中。

ThreadPoolExecutor 其它常见参数:

  • keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是等待,直到等待的时间超过 keepAliveTime 才会被回收销毁。
  • unit:keepAliveTime 参数的时间单位。
  • threadFactory:executor 创建新线程的时候会用到。
  • handler:饱和策略

ThreadPoolExecutor 饱和策略

ThreadPoolExecutor 饱和策略定义:
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满任务时,ThreadPoolTaskExecutor 定义了一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用自己的线程运行任务。这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,该策略喜欢增加队列容量。弱国
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:丢弃最早的未处理的任务请求。

(施工中)