技术提升-并发编程

技术提升-并发编程

课程内容

  • 线程加强
  • 线程池使用和原理
  • 锁的认识
  • 悲观锁-sys
  • JUC并发库了解

一.线程加强

1.线程和进程

1.1.什么是进程

进程是资源(内存等)分配的基本单位,它是程序执行时的一个实例。程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。

在java通过java xxx.class就启动一个进程! main,test,springboot,tomcat, java -jar xx.jar

​ main springboot-- Java -jar 在jvm运行一个程序的实例。 指定堆栈内存。。。

1.2.什么是线程

线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位,一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的栈和局部变量。线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,在web开发中每个请求分配一个线程来处理。

进程是资源分配的最小单位,是程序执行一个实例,线程是程序(代码块)执行的最小单位。

2.java使用线程常见四种方式 --new Thread子类 ,new Thread(runale或者callalbe的任务) ,最终启动线程都是使用start方法,最终线程调度的都是run里面的逻辑

  • 方式一:继承Thread类,覆写run方法,创建Thread子类的实例对象,调用该对象的start方法启动线程
    方式二:创建Runnable接口的实现类,类中覆写run方法,再将实例作为此参数传递给Thread类有参构造创建线程对象,调用start方法启动

    方式三:创建Callable接口的实现类,类中覆写call方法,创建实例对象,将其作为参数传递给FutureTask类有参构造创建FutureTask对象,再将FutureTask对象传递给Thread类的有参构造创建线程对象,调用start方法启动

    Thread有单继承的局限性,Runnable和Callable三避免了单继承的局限,使用更广泛。Runnable适用于无需返回值的场景,Callable使用于有返回值的场景

  • 使用ExecutorService 线程池

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。

2.1.实现 Runnable 接口

需要实现 run() 方法。通过 Thread 调用 start() 方法来启动线程。

在这里插入图片描述

2.2.实现 Callable 接口

与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。

在这里插入图片描述

2.3.继承 Thread 类

同样也是需要实现 run() 方法,并且最后也是调用 start() 方法来启动线程

在这里插入图片描述

2.4.实现接口 VS 继承 Thread

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

Thread和Runable的区别和联系

  • Thread类实现了Runable接口。都需要重写里面Run方法。
  • 不同:实现Runnable的类更具有健壮性,避免了单继承的局限。
  • Runnable更容易实现资源共享,能多个线程同时处理一个资源

​ Java创建线程有两种,一种new Thread子类和new thread,但是具体写的方式(使用线程)有常见如下四种

​ new Thread子类

​ new thread(runable)

​ new thread(new FutrueTask(callable))

​ 线程池,就是提前new直接获取使用

3.线程生命周期 vue生命周期 spring bean的生命周期 servlet生命周期 人的生命周期

从创建到销毁的整个过程!!!

3.1.线程生命周期图

特别注意:当调用start后,线程拥有可执行的权限,但是不一定被执行,只有抢到CPU资源才是running运行状态

在这里插入图片描述

让出cpu 释放锁

3.2.线程的方法(了解)
  • yield方法:使当前线程从执行状态变为就绪状态。 放弃机会,会让出cpu,不会释放锁.
  • sleep方法:强制当前正在执行的线程休眠,当睡眠时间到期,则返回到可运行状态。会让出cpu,不会放弃锁资源
  • join方法:通常用于在main()主线程内,等待其它线程完成再结束main()主线程,会让出cpu,不会放弃锁资源,
  • wait方法:强制当前正在执行的线程等待,直到被唤醒,则返回到可运行状态。会让出cpu,会放弃锁资源
  • deamon:守护线程(deamon)是程序运行时在后台提供服务的线程,并不属于程序中不可或缺的部分。当所有非后台线程结束时,程序也就终止,同时会杀死所有后台线程。main() 属于非后台线程。使用 setDaemon() 方法将一个线程设置为后台线程。 垃圾回收器里面用到的就是守护线程
3.3.线程的状态

新建状态

使用 new 关键字和 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程。

就绪状态

当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度。

运行状态

如果就绪状态的线程获取 CPU 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。

阻塞状态

如果一个线程执行了sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。可以分为三种:

  • 等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态。

  • 同步阻塞:线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)。

  • 其他阻塞:通过调用线程的 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态。当sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 处理完毕,线程重新转入就绪状态。

死亡状态

一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态。

3.4.线程通信

wait,notify,notifyAll ,它们都属于 Object 的一部分,而不属于 Thread。而 sleep() 是 Thread 的静态方法;wait() 会在等待时将线程挂起,而不是忙等待,并且只有在 notify() 或者 notifyAll() 到达时才唤醒。sleep() 和 yield() 并没有释放锁,但是 wait() 会释放锁。实际上,只有在同步控制方法或同步控制块里才能调用 wait() 、notify() 和 notifyAll()。

notify() thread01 obj.wait thread02 obj.wait thread03 obj.wait

该方法用来通知那些可能等待该对象的对象锁的其他线程。如果有多个线程等待,则线程规划器任意挑选出其中一个 wait()状态的线程来发出通知,并使它等待获取该对象的对象锁。

notifyAll()

使所有原来在该对象上 wait 的线程统统退出 wait 的状态(即全部被唤醒,不再等待 notify 或 notifyAll,但由于此时还没有获取到该对象锁,因此还

不能继续往下执行),变成等待获取该对象上的锁。

二.线程池使用和原理

1.线程池介绍

1.1.为什么用线程池

线程池可以看做是管理了N个线程的池子,和连接池类似。线程池的作用主要有:

  • 控制并发数量:线程并发数量过多,抢占系统资源从而导致阻塞,线程池可以限制线程的数量,防止服务器过载。

  • 线程的复用**:创建/销毁线程伴随着系统开销,过于频繁的创建/销毁线程,会很大程度上影响处理效率和速度 . 要从用户态切换到内核态执行,效率低.**

  • 管理线程的生命周期:对线程进行一些简单的管理,创建,销毁等

1.2.认识线程池

​ 创建线程和销毁线程的代价是非常高的,而使用线程池就可以很好的提高性能。线程池在系统启动时创建大量空闲的线程。程序将一个Runable对象或Callable对象传给线程池,线程池就会启动一个线程来执行他们的run()方法或call()方法,当run()方法或call()方法执行结束后,线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个run()方法或call()方法。

在Java 1.5之后就提供了线程池 ThreadPoolExecutor,它的继承体系如下:

在这里插入图片描述

核心类认识:

  • Executor: 线程池顶层接口,提供了execute执行线程任务的方法

  • ThreadPoolExecutor :普通线程池

  • ScheduledExecutorExecutor ** : 带定时任务的线程池(Eureka中30s/次,服务续约,拉取注册表使用的就是该线程池)

  • Execuors: 线程池的工具类,它提供了一些常用的静态方法,可以用它们来创建常用的线程池

    Execuors提供哪些方法创建线程池? java里面有哪些常用的线程池? java有哪些线程池??

2.线程池原理

2.1.线程池七大参数

线程池源码 ThreadPoolExecutor 构造器:

在这里插入图片描述

PS: 线程池7个参数的构造器非常重要[重要]

  • CorePoolSize: 核心线程数,默认不会被销毁

  • WorkQueue:是一个BlockingQueue阻塞队列,超过核心线程数的任务会进入队列排队

    1. SynchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务;
    2. LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;
    3. ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小
  • MaximumPoolSize : 最大线程数 (核心+非核心) ,非核心线程数用完之后达到空闲时间会被销毁

  • Handler: 拒绝策略,任务超过 最大线程数+队列排队数 ,多出来的任务该如何处理取决于Handler

    1. AbortPolicy丢弃任务并抛出RejectedExecutionException异常;
    2. DiscardPolicy丢弃任务,但是不抛出异常;
    3. DiscardOldestPolicy丢弃队列最前面的任务,然后重新尝试执行任务;
    4. CallerRunsPolicy由调用线程处理该任务

    可以定义和使用其他种类的RejectedExecutionHandler类来定义拒绝策略。

  • KeepAliveTime: 非核心线程的最大空闲时间,到了这个空闲时间没被使用,非核心线程销毁

  • Unit: 空闲时间单位

  • ThreadFactory:使用ThreadFactory创建新线程。 推荐使用Executors.defaultThreadFactory

2.2.线程池执行流程

corePoolSize,maximumPoolSize,workQueue之间关系。

1当线程池中线程数小于corePoolSize时,新提交任务将创建一个新线程(使用核心)执行任务,即使此时线程池中存在空闲线程。

  1. 当线程池中线程数达到corePoolSize时(核心用完),新提交任务将被放入workQueue中,等待线程池中任务调度执行 。

  2. 当workQueue已满,且maximumPoolSize > corePoolSize时,新提交任务会创建新线程(非核心)执行任务。

  3. 当workQueue已满,且提交任务数超过maximumPoolSize(线程用完,队列已满),任务由RejectedExecutionHandler处理。

  4. 当线程池中线程数超过corePoolSize,且超过这部分的空闲时间达到keepAliveTime时,回收这些线程。

  5. 当设置allowCoreThreadTimeOut(true)时,线程池中corePoolSize范围内的线程空闲时间达到keepAliveTime也将回收。

线程池执行流程 : 核心线程 => 等待队列 => 非核心线程 => 拒绝策略

3.线程池的使用

3.1.常见的6种线程池

在这里插入图片描述

Executors是一个创建线程池的工具类,可以方便快速的创建很多种类的线程池。配置一个线程池是比较复杂的,尤其对于线程池的原理不清楚的情况下,很有可能配置的线程池不是最优的,因此,在Executors类里定义了一些静态方法,生成一些常用的线程池。如下:

//创建具有缓存功能的线程池,数目无限制
ExecutorService pool1 = Executors.newCachedThreadPool();
//创建具有固定数目的、可重用的线程池
ExecutorService pool2 = Executors.newFixedThreadPool(6);
//创建只有一个线程的线程池
ExecutorService pool3 = Executors.newSingleThreadExecutor();

//创建具有指定线程数的线程池,可以在指定延迟后执行线程任务
ScheduledExecutorService  pool4=Executors.newScheduledThreadPool(6);
//创建具有一个线程的线程池,可在指定延迟后执行线程任务
ScheduledExecutorService  pool5=Executors.newSingleThreadScheduledExecutor();

/*
 *  Java 8 新增的两个方法,这两个方法可以充分利用多CPU并行的能力,
 *    这两个方法生成的work stealing池,相当于后台线程池,如果所有的前台线程死亡了
 *  work stealing线程池中的线程会自动死亡
 */
ExecutorService pool6=Executors.newWorkStealingPool(6);
ExecutorService pool7=Executors.newWorkStealingPool();

//自定义

  • newSingleThreadExecutor:创建一个单线程的线程池。这个线程池中只有一个线程在工作,即相当于单线程串行执行所有任务;如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。任务的执行顺序按照任务的提交顺序执行。
  • newCachedThreadPool:创建一个缓存池大小可根据需要伸缩的线程池,但是在以前构造的线程可用时将重用它们。对于执行很多短期异步执行的程序而言,这些线程池可提高程序性能。
  • newFixedThreadPool(int n):创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程
  • ScheduledThreadPool:带定时任务的线程池
  • newWorkStealingPool(n) 可窃取的线程池
  • 自定义的线程池
3.2.CachedThreadPool可缓存

可缓存线程池-可以无限制创建

在这里插入图片描述

根据源码可以看出:

  • 这种线程池内部没有核心线程,线程的数量是有限制的 最大是Integer最大值。

  • 在提交任务时,若有空闲的线程时则复用空闲的线程(缓存线程),若没有则新建线程。

  • 没有工作的线程(闲置状态)在超过了60S还不做事,就会销毁。

  • 适用:执行很多短期异步的小程序或者负载较轻的服务器。

代码示例

  • 第一步:调用Executor类的静态方法创建一个ExecutorService对象
  • 第二步:创建Runnable实现类或Callable实现类的实例,作为线程执行任务
  • 第三步:调用ExecutorService对象的submit()方法提交Runnable类或Callable类的实例
  • 第四步:当不想提交任何任务时,调用ExecutorService对象的shutdown()方法来关闭线程池
private static void cachedThreadPool() {
    //带缓存的线程,线程复用,没有核心线程,线程的最大值是 Integer.MAX_VALUE
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int  i = 0 ; i < 150 ; i++){
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                //始终只有一个线程在执行
                System.out.println(Thread.currentThread().getName()+":线程执行...");
            }
        });
    }
}
3.3.FixedThreadPool 定长线程池

在这里插入图片描述

根据源码可以看出:

  • 该线程池的最大线程数等于核心线程数,所以在默认情况下,该线程池的线程不会因为闲置状态超时而被销毁。

  • 如果当前线程数小于核心线程数,并且也有闲置线程的时候提交了任务,这时也不会去复用之前的闲置线程,会创建新的线程去执行任务(必须达到最大核心数才会复用线程)。如果当前执行任务数大于了核心线程数,大于的部分就会进入队列等待。等着有闲置的线程来执行这个任务。

  • 适用:执行长期的任务,性能好很多。

代码示例:

private static void fixedThreadPool() {
    ExecutorService executorService = Executors.newFixedThreadPool(5);
    for (int  i = 0 ; i < 150 ; i++){
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                //有5个线程在执行
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+":线程执行...");
            }
        });
    }
}
3.4.SingleThreadPool 单线程池

在这里插入图片描述

根据源码可以看出:

  • 有且仅有一个工作线程执行任务

  • 所有任务按照指定顺序执行,即遵循队列的入队出队规则。

  • 适用:一个任务一个任务执行的场景。 如同队列

代码示例:

//单线程的线程池
private static void singleThreadExecutor() {
    //单线程的线程池
    ExecutorService executorService = Executors.newSingleThreadExecutor();
    for (int  i = 0 ; i < 10 ; i++){
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                //始终只有一个线程在执行
                System.out.println(Thread.currentThread().getName()+":线程执行...");
            }
        });
    }
}
3.5. ScheduledThreadPool 定时任务

在这里插入图片描述

根据源码可以看出:

  • DEFAULT_KEEPALIVE_MILLIS就是默认10L,这里就是10秒。这个线程池有点像是CachedThreadPool和FixedThreadPool 结合了一下。

  • 不仅设置了核心线程数,最大线程数也是Integer.MAX_VALUE。

  • 这个线程池是上述4个中唯一一个有延迟执行和周期执行任务的线程池。

  • 适用:周期性执行任务的场景(定期的同步数据),Eureka就使用了该线程池

代码示例:

private static void scheduledThreadPool() {
    //带缓存的线程,线程复用,没有核心线程,线程的最大值是 Integer.MAX_VALUE
    ScheduledExecutorService executorService = Executors.newScheduledThreadPool(5);
    //延迟 n 时间后,执行一次,延迟任务
    executorService.schedule(new Runnable() {
        @Override
        public void run() {
            System.out.println("延迟任务执行.....");
        }
    },10, TimeUnit.SECONDS);
    
    //定时任务,固定 N 时间执行一次 ,按照上一次任务的开始执行时间计算下一次任务开始时间
    executorService.scheduleAtFixedRate(()->{
        System.out.println("定时任务 scheduleAtFixedRate 执行 time:"+System.currentTimeMillis());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    },1,1,TimeUnit.SECONDS);
    
  一个任务2 间隔2
      00  02  04  06 
      00  04   08
    //定时任务,固定 N 时间执行一次 ,按照上一次任务的结束时间计算下一次任务开始时间
    executorService.scheduleWithFixedDelay(()->{
        System.out.println("定时任务 scheduleWithFixedDelay 执行 time:"+System.currentTimeMillis());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    },1,1,TimeUnit.SECONDS);
    
}

总结:除了new ScheduledThreadPool 的内部实现特殊一点之外,其它线程池内部都是基于ThreadPoolExecutor类(Executor的子类)实现的。

3.6. WorkStealingPool

java8才引入,一个高消息的线程. 提高性能! 工作窃取算法. 执行时间不均匀的时候效率高.

3.7.自定义ThreadPoolExecutor

通过new ThreadPoolExecutor 来自定义线程池

private static void customThreadPool() {
    //核心 4 个 ,最大 10 个 ,60s的空闲销毁非核心6个线程, 队列最大排队 10 个   = 最多同时处理 20个拒绝
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4, 10,
            60L, TimeUnit.SECONDS,  //最大空闲时间
            new ArrayBlockingQueue<Runnable>(10),   //队列排队10个
            new ThreadPoolExecutor.DiscardPolicy());    //任务满了就丢弃

    for (int  i = 0 ; i < 210 ; i++){
        int finalI = i;
        threadPoolExecutor.execute(new Runnable() {
            @Override
            public void run() {
                //始终只有一个线程在执行
                System.out.println(Thread.currentThread().getName()+":线程执行..."+ finalI);
            }
        });
    }

}

4.ThreadPoolExecutor重要的方法

4.1.Execute 执行任务

方法实际上是Executor中声明的方法,在ThreadPoolExecutor进行了具体的实现,这个方法是ThreadPoolExecutor的核心方法,通过这个方法可以向线程池提交一个任务,交由线程池去执行。

4.2.Submit 执行任务

方法是在ExecutorService中声明的方法,在AbstractExecutorService就已经有了具体的实现,在ThreadPoolExecutor中并没有对其进行重写,这个方法也是用来向线程池提交任务的,实际上它还是调用的execute()方法,只不过它利用了Future来获取任务执行结果。

4.3.Shutdown 终止

不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务。

4.4.shutdownNow 终止

立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务。

4.5.isTerminated 是否终止

调用ExecutorService.shutdown方法的时候,线程池不再接收任何新任务,但此时线程池并不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。在调用shutdown方法后我们可以在一个死循环里面用isTerminated方法判断是否线程池中的所有线程已经执行完毕,如果子线程都结束了,我们就可以做关闭流等后续操作了。

5.线程池中的最大线程数

一般说来,线程池的大小经验值应该这样设置:(其中N为CPU的核数)如果是CPU密集型应用,则线程池大小设置为N+1,如果是IO密集型应用,则线程池大小设置为2N+1 32X2+1=65 64X2+1=129,如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。

但是,IO优化中,这样的估算公式可能更适合:最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU核数

因为很显然,线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。创建线程的个数是还要考虑 内存资源是否足够装下相当的线程

下面举个例子:

比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32。

最佳实践:通过压测来得到最优的线程数。

三.ThreadLocal使用和原理

1.ThreadLocal的认识

1.1.一个面试题引发的血案

面试官问:SpringMVC的Controller是单利还是多利?其中的成员变量是否有线程安全问题,如何解决???

解决方案:一是使用局部变量,二是使用 ThreadLcal 来实现。

1.2.什么是ThreadLcal

ThreadLocal 是解决线程安全问题一个很好的思路,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。在很多情况下,ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。

简单理解可以把ThreadLocal看做是一个key-value结构底层维护了一个ThreadLocal.ThreadLocalMap结构,ThreadLocal自动把当前线程副本作为Key,而Value就是我们存储的值。举例:在A线程中存储一个元素到ThreadLocal,也只能在A线程中才能取出这个值,其他线程获取不到,从而就没有了线程并发问题。

ThreadLcal使用场景

由于ThreadLcal可以把数据绑定到当前线程,所以只要把数据设置到ThreadLcal,只要程序还是在同一个线程中执行,无论在什么地方什么时候都可以直接通过ThreadLcal取值,无效方法传递参数,非常方便,所以在众多框架中ThreadLcal的身影也我非常多的。

  1. RequestContextHolder :保存请求对象
  2. LocaleContextHolder :Spring国际化相关
  3. InheritableThreadLocalSecurityContextHolderStrategy :Security存储SecurityContext
1.3.ThreadLcal的API

ThreadLcal提供了两个方法

  • threadLocal.set(“value”) :设置值,把值和当前线程绑定
  • threadLocal.get() : 从当前线程中取出绑定的值。

来看一个案例

public static void main(String[] args) {
        ThreadLocal<String> threadLocal = new ThreadLocal<>();
   
        threadLocal.set("main-value");
        
        System.out.println("main线程获取:"+threadLocal.get());  //打印 main-value
    }

2.ThreadLocal源码

2.1.set方法

方法的执行流程如下

  • 拿到当前线程Thread对象,Thread中包含了ThreadLocal.ThreadLocalMap类型的字段threadLocals
  • 从Thread拿到ThreadLocal.ThreadLocalMap
  • 以this(ThreadLocal)作为key,把设置的value保存到ThreadLocal.ThreadLocalMap中,ThreadLocalMap底层使用 ThreadLocal.ThreadLocalMap.Entry 结构存储key-value

源码如下

class ThreadLocal{ 
    ...省略...
    public void set(T value) {
            //1.拿到当前线程
            Thread t = Thread.currentThread();
            //2.拿到当前线程Thread中的ThreadLocal.ThreadLocalMap
            ThreadLocalMap map = getMap(t);
            if (map != null)
                //3.这里的this是ThreadLocal,把ThreadLocal作为key,把传入的值作为值,存储到Thread中的ThreadLocalMap中
                map.set(this, value);
            else
                createMap(t, value);
     }

    //ThreadLocalMap 在 Thread中
     ThreadLocalMap getMap(Thread t) {
         return t.threadLocals;
     }
}

Thread中包含ThreadLocal.ThreadLocalMap

public class Thread implements Runnable {
	ThreadLocal.ThreadLocalMap threadLocals = null;
    ...省略...
}

ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。所以 ThreadLocal 用于线程间的数据隔离。

在这里插入图片描述

2.2.put方法

该方法的执行流程如下

  • 拿到当前线程Thread对象,Thread中包含了ThreadLocal.ThreadLocalMap类型的字段threadLocals
  • 从Thread拿到ThreadLocal.ThreadLocalMap
  • 以this(ThreadLocal)作为key,拿到ThreadLocalMap.Entry,取value作为值返回
public T get() {
    	//1.拿到线程
        Thread t = Thread.currentThread();
    	//2.拿到线程中的ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            //以ThreadLocal作为key,
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                //3.返回值
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
2.3.内存泄漏问题

ThreadLocalMap中用于存储数据的entry定义,使用了弱引用,可能造成内存泄漏。

当线程没有结束,但是ThreadLocal对象已经被回收,则可能导致线程中存在ThreadLocalMap的键值对,造成内存泄露。(ThreadLocal被回收,ThreadLocal关联的线程共享变量还存在)。解决办法:

  • 使用完线程共享变量后,显式调用ThreadLocalMap.remove方法清除线程共享变量;

  • ThreadLocal定义为private static,这样ThreadLocal的弱引用问题则不存在了。

四.锁的使用

1.锁的认识

1.1.线程安全概述

​ 有的代码本身就是线程安全,但是有的存在共享资源,默认线程不安全,但是可以通过 一定方式保证安全.

1.2.线程安全的实现方法

​ 线程安全的保证一般使用来实现,锁的一种宏观分类方式是悲观锁和乐观锁。悲观锁与乐观锁并不是特指某个锁(Java中没有哪个Lock实现类就叫PessimisticLock或OptimisticLock),而是在并发情况下的两种不同策略

1.3.乐观锁和悲观锁
  • 悲观锁(Pessimistic Lock):

定义:就是很悲观,每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。这样别人想拿数据就被挡住,直到悲观锁被释放。

互斥同步:互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

举例:synchronizedReentrantLock

  • 乐观锁(Optimistic Lock):

定义:就是很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!但是如果 想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过, 则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作)。

说到乐观锁,就必须提到一个概念:CAS,什么是CAS呢?Compare-and-Swap,即比较并替换,也有叫做Compare-and-Set的,比较并设置。

非阻塞同步:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共 享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见 的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。

举例:CAS ,Atomic

1.4.锁相关概念

Java中的锁

在这里插入图片描述

解释:

是否要加锁

  • 悲观锁是要加锁的,如Synchronization ,ReentrantLock

  • 乐观锁是不加锁的:如对于Mysql中的数据而言,可以通过版本号,时间戳等来判断数据是否被并发修改

是否要阻塞

  • 互斥锁(阻塞):如果一个线程尝试获取锁失败,可以进行阻塞等待别人释放锁后再尝试获取锁,如Synchronization

  • 自旋锁(不阻塞):当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)。

  • 适应性自旋锁(不阻塞):在自旋锁的基础上自旋,尝试一定的次数还是获取不到锁就放弃获取锁,这种模式叫适应性自旋。

是否要排队加锁

  • 公平锁(排队加锁):多个线程都在竞锁时是否要按照先后顺序排队加锁,如果是那就是公平锁

  • 非公平锁(不排队加锁):多个线程都在竞锁时不需要排队加锁,是为非公平锁

是否可重入

  • 可重入锁:允许同一个线程多次获取同一把锁,是为可重入锁:比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。Java里只要以Reentrant开头命名的锁都是可重入锁,Synchronization 也是可重入的

  • 非可重入锁:一个线程在多个流程中不可用获取到同一把锁,是为非重入锁

可否共享锁

  • 共享锁:多个线程可以共享一把锁,如多个线程同时读,一般是可共享读锁 :如读锁
  • 排他锁:多个线程不可用共享一把锁,比如修改数据时别人是不能修改的的:如写锁 (Mysql)

在java中,你使用相关的关键字或者类,就会上锁

2 线程同步Synchronized

2.1. Synchronized的用法

synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:

  • 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
  • 修饰一个非静态方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
  • 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;

代码示例

package cn.ronghuanet._03sys;

public class SysTest {

    //开四个窗口买
    public static void main(String[] args) {

        MyTicketRunable ticketRunable = new MyTicketRunable();
        for (int i = 0; i < 4; i++) {
            new Thread(ticketRunable).start();
        }

    }
}

class MyTicketRunable implements Runnable
{
    //private int ticketNum = 100;
    private static int ticketNum = 100;

    @Override
    public void run() {
        while (true){
            /*
            synchronized (this){ //同步代码块
                if (ticketNum>0){
                    ticketNum--;
                    System.out.println(ticketNum+"==="+Thread.currentThread().getName()+"==================");
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }else
                {
                    return;
                }
            }*/

            //this.saleTicket();

            saleStaticTicket();

        }
    }

    public synchronized void saleTicket(){
        //同步普通方法---锁便是对象就是this
        if (ticketNum>0){
            ticketNum--;
            System.out.println(ticketNum+"==="+Thread.currentThread().getName()+"==================");
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }else
        {
            return;
        }
    }

    public static synchronized void saleStaticTicket(){
        //同步静态方法---锁标识对象就是当前类字节码对象
        if (ticketNum>0){
            ticketNum--;
            System.out.println(ticketNum+"==="+Thread.currentThread().getName()+"==================");
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }else
        {
            return;
        }
    }

}

2.2.Synchronized 原理—通过竞争锁标识对象monitor来实现

synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实 现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖 底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。当 然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等 技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。Synchronized关键字在编译的字节码中加入了两条指令来进行代码的同步。

monitorenter:加锁

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

Monitorexit :释放

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

  • 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。
  • 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在这里插入图片描述

简单理解:Synchronized基于JVM内置锁实现,通过锁表示对象Monitor(监视器锁)实现,通过进入与退出对象的Monitor来实现方法与代码块同步。JDK1.5之后 ,Synchronized会从无锁升级为偏向锁,再升级为轻量级锁,最后升级为重量级锁。后续还会说到Synchronized的优化。

在这里插入图片描述

2.3.synchronized的优化

用户态和内核态

  • 内核态

CPU可以访问内存所有数据,包括外围设备,例如硬盘,网卡。CPU也可以将自己从一个程序切换到另一个程序

  • 用户态

只能受限的访问内存,切不允许访问外围设备占用CPU的能力被剥夺,CPU资源可以被其他程序获取。之所以会有这样的却分是为了防止用户进程获取别的程序的内存数据,或者获取外围设备的数据。

Synchronized原本是依赖操作系统实现的,因此在使用synchronized同步锁的时候需要进行用户态到内核态的切换,简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。

jdk 1.6及其之后的优化

对象头:每个对象都拥有对象头,对象头由Mark World ,指向类的指针,以及数组长度三部分组成,锁升级主要依赖Mark Word中的锁标志位和释放偏向锁标识位。

  • 偏向锁(无锁)

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁的目的是在某个线程 获得锁之后(线程的id会记录在对象的Mark Word锁标志位中),消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。(第二次还是这个线程进来就不需要重复加锁,基本无开销),如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

  • 轻量级锁(CAS):

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁自旋锁);没有抢到锁的线程将自旋,获取锁的操作。轻量级锁的意图是在没有多线程竞争的情况下,通过CAS操作尝试将MarkWord锁标志位更新为指向LockRecord的指针,减少了使用重量级锁的系统互斥量产生的性能消耗。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)

  • 重量级锁:

如果锁竞争情况严重,某个达到最大自旋次数**(10次默认)的**线程,会将轻量级锁升级为重量级锁,重量级锁则直接将自己挂起,在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。

虚拟机使用CAS操作尝试将MarkWord更新为指向LockRecord的指针,如果更新成功表示线程就拥有该对象的锁;如果失败,会检查MarkWord是否指向当前线程的栈帧,如果是,表示当前线程已经拥有这个锁;如果不是,说明这个锁被其他线程抢占,此时膨胀为重量级锁。

3.线程并发库组成(lock,atomic)

其实,显示锁(Lock)是属于线程并发库(java.util.concurrent)里面一种功能,java.util.concurrent是专门Java并发设计编程包。所以先来看一下线程并发库的的一些功能。

3.1. 显示锁 java.util.concurrent.locks

现实锁:需要自己显示的加锁,比如 Synchronized 使用的是JVM内置锁实现的,它就不是显示锁

在这里插入图片描述

3.2. 原子变量类 (乐观锁)

java.util.concurrent.atomic:为了实现原子性操作提供的一些原子类,使用的是乐观锁实现

在这里插入图片描述

3.3.线程池相关 java.util.concurrent

通过线程池操作线程可以增加线程的复用性,防止频繁的创建,销毁线程。

在这里插入图片描述

3.4.并发容器类 java.util.concurrent

并发容器都是线程安全的,比如在多线程中可以使用ConcurrentHashMap代替HashMap

在这里插入图片描述

ArrayBlcokingQueue:有长度阻塞队列

LinkedBlockingQuery:无限长度阻塞队列

SynchronousQueue:没有长度,就是不存

3.5.同步工具类 java.util.concurrent

在这里插入图片描述

4.线程同步-lock

4.1.Lock概述

在java中锁的实现主要有两类:内部锁 synchronized(对象内置的monitor锁)和显示锁java.util.concurrent.locks.Lock。在 java.util.concurrent.locks 包中有很多Lock的实现类,常用的有 ReentrantLock 和ReadWriteLock,其实现都依赖 java.util.concurrent.AbstractQueuedSynchronizer(AQS) 类。

4.2.Lock的使用

lock是一个接口,主要有以下几个方法:

  • lock():获取锁,如果锁被暂用则一直等待

  • unlock():释放锁

  • tryLock(): 注意返回类型是boolean,如果获取锁的时候锁被占用就返回false,否则返回true

  • tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,操作这个时间期限拿不到锁就返回false。

代码示例

public class RunnableDemo implements Runnable {

    private int number = 100;

    private String name = "";

    RunnableDemo(String name){
        this.name = name;
    }

    //创建锁对象
    Lock lock = new ReentrantLock();

    @Override
    public  void run() {

        System.out.println("线程:"+this.name+" 执行...");
        for (int i  = 0 ; i < 100 ; i++){
            //加锁
            lock.lock();
            try{
                if(number > 0){
                    Thread.sleep(100);
                    System.out.println(Thread.currentThread().getName()+":卖出:"+number);
                    number = number - 1;
                }
            }catch (Exception e){

            }finally {
                //释放锁
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        RunnableDemo runnableDemo = new RunnableDemo("RunnableDemo");
        new Thread(runnableDemo).start();
        new Thread(runnableDemo).start();
    }
}
4.3. synchronized与Lock的区别

(1)Lock的加锁和解锁都是由java代码实现的,而synchronize的加锁和解锁的过程是由JVM管理的。

(2)synchronized能锁住类、方法和代码块,而Lock是块范围内的。

(3)Lock能提高多个线程读操作的效率;

在这里插入图片描述

5.线程安全-乐观锁

5.1.乐观锁概述

乐观锁是不加锁的,只是在修改数据的时候先做判断,如果数据没被别人修改即可提交修改,否则不做修改,做出重试或其他的补偿行为,在Java中 Atomic开头的类就是基于CAS实现的乐观锁。

5.2.Atomic案例

AtomicInteger

AtomicInteger使用了CAS实现的乐观锁。即:比较和交换, 乐观锁全程不加锁,但在做数据交换的时候会先比较一下原本数据是否被别的线程修改(是否有线程并发),如果没有,就修改数据,否则就不做修改。源码如:

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

案例如下

public class AtomicDemo implements Runnable {

    private AtomicInteger number = new AtomicInteger(100);

    private String name = "";
    AtomicDemo(String name){
        this.name = name;
    }

    @Override
    public  void run() {

        System.out.println("线程:"+this.name+" 执行...");
        for (int i  = 0 ; i < 100 ; i++) {
            if (number.get() > 0) {
                try {
                    Thread.sleep(100);
                    //获取和递减值,内部基于乐观锁保证原子性
                    System.out.println(Thread.currentThread().getName() + ":卖出:" +  number.getAndDecrement());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        AtomicDemo runnableDemo = new AtomicDemo("RunnableDemo");
        new Thread(runnableDemo).start();
        new Thread(runnableDemo).start();
    }
}
5.3.乐观锁使用场景

数据库通过version控制

在数据库层面我们也通常使用乐观锁来保证数据的别发修改问题,通常是在每一行数据增加 version,或时间戳 ,每次数据修改增加 where version = #{version} 条件,判断数据库中的版本号和修改之间读出来的版本号是否一致,其实是在判断从读数据,到修改数据的时间段内,别的事物是否修改了该数据,如果version匹配,说明没有问题,可以直接修改,否则可以作出重试,或者回滚事物等处理方式,案例如:

  1. 查询对象
//sql
select id,version,username from t_user where id = #{id}
//代码调用
User user = userMapper.selectById(1L);

2.修改数据库

//代码调用
user.setUsername(“zs”);
userMapper.updateById(user);

//sql
Update t_user set username = #{username} , version = version + 1 where id = #{id} and version = #{version} 

//乐观锁

当然也可以使用UUDI,时间戳的方式控制乐观锁。另外ES也是维护了一个Version来保证并发安全。

五.总结

1.重点内容

  • 线程池7个参数
  • 四种线程池
  • 定时任务线程池
  • 乐观锁和悲观锁
  • Atomic原子类
  • Synchronized原理
  • Lock的使用
  • CAS&AQS
  • 线程三种方式

2.面试必备

  • 创建线程有几种方式
  • thread中的run和start方法有什么区别
  • Thread和Runnable的区别
  • 哪种线程有返回值
  • Synchronized 和 lock的区别
  • Synchronized 锁升级原理
  • 说一下悲观锁和乐观锁
  • 乐观锁的使用场景&你用过的框架中哪儿用到了乐观锁&或者你在什么场景用到了乐观锁
  • 线程没有抢到锁,可以作出哪些反映
  • 线程的生命周期&几种状态
  • sleep 和 wait的区别
  • Synchronized 加在方法上 锁的对象是什么 ? 加在静态方法上锁住的对象是什么?
  • Synchronized(this) 和 Synchronized (User.class) 的区别
  • Synchronized 和 volatitle 关键字的区别
  • 一个类中两个方法中都有Synchronized(this) 请问能锁住吗?为什么
  • 讲几个线程并发库中的类
  • 你对ConcurrentHashMap的理解
  • ThreadLocal的作用&使用场景
  • ThreadLocal底层结构了解吗?
  • 线程池的7大参数
  • 线程池的执行流程
    可以作出重试,或者回滚事物等处理方式,案例如:
  1. 查询对象
//sql
select id,version,username from t_user where id = #{id}
//代码调用
User user = userMapper.selectById(1L);

2.修改数据库

//代码调用
user.setUsername(“zs”);
userMapper.updateById(user);

//sql
Update t_user set username = #{username} , version = version + 1 where id = #{id} and version = #{version} 

//乐观锁

当然也可以使用UUDI,时间戳的方式控制乐观锁。另外ES也是维护了一个Version来保证并发安全。

五.总结

1.重点内容

  • 线程池7个参数
  • 四种线程池
  • 定时任务线程池
  • 乐观锁和悲观锁
  • Atomic原子类
  • Synchronized原理
  • Lock的使用
  • CAS&AQS
  • 线程三种方式

2.面试必备

  • 创建线程有几种方式
  • thread中的run和start方法有什么区别
  • Thread和Runnable的区别
  • 哪种线程有返回值
  • Synchronized 和 lock的区别
  • Synchronized 锁升级原理
  • 说一下悲观锁和乐观锁
  • 乐观锁的使用场景&你用过的框架中哪儿用到了乐观锁&或者你在什么场景用到了乐观锁
  • 线程没有抢到锁,可以作出哪些反映
  • 线程的生命周期&几种状态
  • sleep 和 wait的区别
  • Synchronized 加在方法上 锁的对象是什么 ? 加在静态方法上锁住的对象是什么?
  • Synchronized(this) 和 Synchronized (User.class) 的区别
  • Synchronized 和 volatitle 关键字的区别
  • 一个类中两个方法中都有Synchronized(this) 请问能锁住吗?为什么
  • 讲几个线程并发库中的类
  • 你对ConcurrentHashMap的理解
  • ThreadLocal的作用&使用场景
  • ThreadLocal底层结构了解吗?
  • 线程池的7大参数
  • 线程池的执行流程
  • 你在什么业务中使用到了线程池
  • 29
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

谦谦孑

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

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

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

打赏作者

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

抵扣说明:

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

余额充值