Java并发

并发

并发推荐博客,和自我笔记

为什么需要并发

在以前计算机是单核CPU的时候,不会存在并发,因为每次只能执行一个任务。随着科技进步,CPU性能的提高,由之前的单核CPU进化成多核CPU。如果依旧同时只存在一个任务运行,则CPU的性能会大大浪费,因此产生的并发,要求多个任务一起运行。

并发的实现方式

最开始并发的实现是基于进程实现的并发。但是因为创建进程的资源消耗比较大,所以选择了比进程体量更小的线程。

线程是指一个单一的控制流。一个进程中可以有多个线程并发的执行,每一个线程执行一个不同的任务。在Java中,Java中的线程和操作系统的线程1:1相同的,即可以理解Java中的线程就是操作系统中的线程。

多线程是多任务的一种表现形式,但是多线程与多进程相比,使用了更小的资源开销。进程内的多个线程会共享进程中的内存空间。

一个进程包括了由操作系统分配的空间,以及一个或者多个线程。进程是资源分配的最小单位,线程是进行资源调度的最小单位。一个线程不能独立存在,只能存在与线程的内部。

线程

线程的生命周期

线程的生命周期主要有五个部分:创建、阻塞、运行、就绪和死亡,他们之间的转换关系如图所示。

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/62bb94b5580a4182be843e8c36652f16~tplv-k3u1fbpfcp-zoom-1.image

线程的运行是不能由程序员决定的,是由操作系统进行任务调度的时候,执行线程。

线程池

线程池的由来

当我们使用多线程技术解决系统的并发能力的时候,会重复的去创建线程、销毁线程。而创建线程和销毁线程的开销会比较大。除此之外,可能页会更加的关注这多个任务之间的关系,以及对任务进行管理和维护等,线程池都可以很好的帮我进行管理和维护。

创建线程的开销:内存(堆栈内存使用的是JVM外的内存)、线程的切换。具体可以参考文章

使用线程池技术可以动态的维护线程的创建和销毁,以及维护任务之间的执行的顺序任务管理等。

创建线程池的方式

Java中提供了一系列创建线程池的方式,可以选择使用预先设定的线程池,也可以自己根据业务特点自己创建线程池。


线程池的类型

Java在Executors中提供了五种默认的线程池,可以在不同的场景使用,分别是:

  • newFixedThreadPool

固定线程数的线程池,无论线程池中的任务数,线程的数量是固定的。是一个无界队列的线程池,如果任务巨多,会出现OOM的情况。

  • newSingleThreadExecutor

创建一个单线程的线程池,线程池中有且仅有一个线程,如果在执行过程中出现异常,线程死亡,会重新创建一个新的线程顶替。与newFixedThreadPool 不同的是,该线程池返回的线程不可重新配置。他使用的队列也是无界队列。

  • newCachedThreadPool

创建的线程数最大为Integer.MAX_VALUE,如果之前创建的线程不可用,则会创建一个新的线程,如果之前创建的线程可用,则会使用已经存在的线程。该线程的队列无存储空间,因为每到一个任务,便会执行一个任务。

  • newScheduledThreadPool

创建一个可以定时或者延迟执行任务的线程池。创建的最大的线程数量为Integer.MAX_VALUE ,使用的队列是无界延时队列。

  • newWorkStealingPool

创建一个可以保证并行度的线程池,线程池在执行过程中,可能会动态的增加或者减少线程的数量。

创建线程池的方式

除了上述的五种默认的线程池外,可以根据业务场景,创建适合场景的线程池。创建线程池的全部参数如下:

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
  • corePoolSize:核心线程数的数量。当提交的任务到达的时候,如果当前线程已经被占用,并且现有的线程数小于核心线程数,就会去创建核心线程数,并执行任务。
  • maximumPoolSize:最大线程数。当任务继续提交,并且核心线程数满的时候,就会将任务加入到[workQueue]() 中,如果workQueue也满了,就会判断当前的线程数是否小于最大的线程数,如果小于最大的线程数,则会创建线程,执行任务。
  • unit/keepAliveTime:线程存活的时间和时间的单位。默认情况下,如果现有的线程数大于核心线程数,并且线程空闲的时间超过了设置的时间,就会挥手线程。默认情况下不会回收核心线程。如果想要回收核心线程,可以设置allowCoreThreadTimeOut 为true,核心线程在达到回收条件的时候,就会被回收。
  • workQueue:阻塞队列。当线程数达到核心线程的时候,就会将新到的任务放入到阻塞队列中。当核心线程空闲的时间,会从阻塞对列中获取任务,并执行任务。
  • threadFactory:线程工厂,用于创建线程的信息。配置的线程工厂,会在创建线程的时候,使用该工厂创建线程。使用工厂创建线程,可以为一组线程设置一些与业务相关的配置,如果出现异常或者是排查问题的时候,可以通过线程的信息,判断出现问题的业务类型。
  • handler:拒绝策略。当线程数已经达到了最大的线程数,并且阻塞队列已经满了,依旧有任务继续达到,对任务接收的处理策略。Java中提供了四种默认的拒绝策略,分别是:直接丢弃;使用当前线程执行任务;丢弃最老的任务;抛出异常。如果需要针对不同的场景,可以通过继承接口的方式实现自定义的处理。

创建线程池的时候,可以根据上面的参数和使用的时机,设置不同的参数,以达到预期的效果。

线程池的整个模型是一个生产者-消费者的模型:业务线程负责生产数据,将生产的内容放入到线程池中,线程池中的线程负责消费数据,是一个典型的生产者消费者模型。

注意点:如果使用线程池的execute执行任务的时候,如果任务失败,抛出异常,异常信息会被线程池吞掉,导致代码中没有异常信息,所以在使用线程池的时候,一定要注意异常的处理。

多线程带来的问题

当使用并发编程时,可以提高CPU的利用效率,提高程序的执行速度。但是如果同时出现多个线程一起修改同一个共享变量的时候,就会出现数据的错误。例如,如果两个线程同时对字段进行自加,可能就会出现最终的结果和预期的结果不相符。

出现问题的原因

  1. 缓存带来的一致性问题

    在单核CPU中,由于一个CPU只会有一个核心,所以CPU的缓存也只会存在于一个区域中。在多核CPU中,每一个核心都会有对应的缓存,每一个缓存之间的数据可能出现不同步。当CPU从缓存中读取数据的时候,读取到的缓存的值可能是不一样的。当CPU在写入写入数据的时候,会先写入到缓存,写入缓存后,更新到主存的过程中,就会出现数据的覆盖,导致数据丢失。

  2. 线程切换带来的原子性问题

    在多线程中,一个线程不能一直运行,需要在运行一段时间后,在操作系统的调度下,需要进行线程的切换。当切换线程的时候,该线程对数据的处理可能还未完成,但是因为线程的切换导致线程不得不停止,下一次线程再次运行的时候会继续从切换前的状态继续运行。如果在切换有,有其他的线程更新了数据,就会导致该线程现在读取的数据是脏数据,导致数据错误。

  3. 编译优化带来的有序性问题

    CPU在执行任务的时候,并不会完全按照程序员编写的代码严格的执行。CPU会在不影响执行结果的情况下,会将指令之间的顺序进行修改,以提高访问的效率。例如在创建对象的时候,正常流程下是先去申请一块内存,然后初始化对象的值,创建一个引用指向初始化的对象,然后返回值。CPU在进行优化后,可能会先去申请一块内存,然后创建一个引用,执行该内存,然后再去初始化。如果在初始化完成之前,有其他的对象调用了该对象的内存,就会出现异常错误。参考笔记

为了解决以上的问题,Java引入了一系列的工具用于解决并发中可能出现的问题。


解决方案

Java内存模型

Java中的内存模型,简称JMM,指的是一套Java中用于限制指令重排和禁用缓存的规范,主要的实现是由个JVM厂商完成。

一个简单的问题

// 以下代码来源于【参考1】
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

在JDK1.5之前,返回的值可能是0,也可能是42。JDK 1.5之后返回42。因为在JDK 1.5之后,引入了Happens-Befores规则。

Happens-Befores规则

含义:前面一个的操作结果,对于后面一个的操作是可见的。

  1. 程序的顺序性规则

    例如上面的例子中,x = 42的修改,对与 v = true是可见的。

  2. volatile变量规则

    volatile的写操作对后续volatile变量的读操作是可见的。例如 v = true 对于 if (v == true) 是可见的。

  3. 传递性

    如果A Happens-Before B,B Happens-Before C,则A Happens-Before C。

  4. 管程中锁的规则

    管程中对一个锁的解锁 Happens-Before 对锁的加锁。例如

    synchronized (this) { //此处自动加锁
      // x是共享变量,初始值=10
      if (this.x < 12) {
        this.x = 12; 
      }  
    } //此处自动解锁
    

    线程A对x的修改,对线程B是可见的。

  5. 线程start规则

    主线程A启动子线程B后,子线程B能够看到主线程A启动子线程B前的操作。

  6. 线程join规则

    主线程 A等待子线程B完成(主线程A调用子线程B的join()方法阻塞实现),当子线程B完成后,主线程A能够看到子线程对共享变量的操作。

  7. 线程中断规则

    对线程interrupt()的方法调用先行发生于被中断线程的代码检测到中断事件的发生

  8. 对象终结规则

    一个对象的初始化(构造函数的完成)先行发生于他的finilize()方法的开始

管程

什么是管程

管程:英文是Monitor,是指一个管理共享变量以及对共享变量操作过程,让共享变量支持并发的模型。所以管程是一种模型,描述了对共享变量的操作方式。管程和信号量相比,是等价的,可以使用管程实现信号量,也可以使用信号量实现管程。

管程的模型

  1. Hasen 模型:要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
  2. Hoare 模型:T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
  3. MESA 管程:T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

管程解决并发的思想都是把共享变量的操作进行包装。

839377608f47e7b3b9c79b8fad144065.png

实现管程的方式

synchronized关键字

synchronized关键字实现锁的原理是借助了JVM为每一个对象提供的对象头信息。当使用synchronized锁定一个对象的时候,会把当前获取锁成功的线程ID写入到对象头信息中。如果一个线程在获取锁的时候,发现对象头中已经存在有其他线程的信息,则该线程获取锁失败,如果该对象的对象头中,不存在锁,则会把当前线程的锁信息写入到对象头;

synchronized的锁升级过程

锁升级中的状态有:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。

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

轻量级锁:线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。

重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

锁消除:Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁

Lock和Condition

Lock和Condition是Java中提供的基于SDK实现的管程的方式,期中Lock用于解决互斥问题,Condition用于解决同步问题,对应并发编程问题中的互斥和同步。

如何解决可见性问题

Lock解决可见性问题的方式和synchronized的方式不一致,synchronized解决可见性问题的方式是基于synchronized的Happens-Before原则,Lock是基于volatile关键字的Happens-Before原则。

新建管程的原因

在造成死锁的四个条件中,synchronized无法做到 破坏不可抢占条件,因为sychronized在申请资源的时候,如果申请的资源不可用,线程会进入阻塞状态,进入阻塞状态后便不能释放已经持有的资源。如果在线程申请不到资源的时候,主动放弃已经申请的资源,便可以打破这个条件。

所以只要新提供的方法可以做到以下三点,便可打破不可抢占的条件:

  1. 能够响应中断当线程阻塞时,如果能够响应中断,就可以有机会释放已经获取的锁。
  2. 支持超时如果线程在一定时间内未获取到锁,则释放自己的所获得的资源
  3. 非阻塞的获取锁如果尝试获取锁失败,并不进入阻塞状态,而是直接返回。

所以Lock提供了三个方法:

// 支持中断的API
void lockInterruptibly() 
  throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit) 
  throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();

以上三种方式便是对应破坏不可抢占的条件的三种方式。

造成死锁的四个条件:互斥、不可抢占、循环等待、占有且等待。

synchronized解决循环等待的方式:当发现资源不可用的时候,可以调用wait方法,释放自己已经获得的资源;
synchronized解决占有且等待的方式:在获取资源的时候,可以一次性获取全部需要的资源,如果其中一个无法获取,则放弃整个资源的获取。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值