1

前言:
Java并发编程学习分享的目标:

Java并发编程中常用的工具用途与用法;

Java并发编程工具实现原理与设计思路;

并发编程中遇到的常见问题与解决方案;

根据实际情景选择更合适的工具完成高效的设计方案

学习分享团队:
学而思培优-运营研发团队
Java并发编程分享小组:
@沈健 @曹伟伟 @张俊勇 @田新文 @张晨
本章分享人:@张晨

学习分享大纲:
image.png

01初识并发

什么是并发,什么是并行?

用个JVM的例子来讲解,在垃圾回收器做并发标记的时候,这个时候JVM不仅可以做垃圾标记,还可以处理程序的一些需求,这个叫并发。在做垃圾回收时,JVM多个线程同时做回收,这叫并行。

02为什么要学习并发编程

直观原因
1)JD的强制性要求
随着互联网行业的飞速发展,并发编程已经成为非常热门的领域,也是各大企业服务端岗位招聘的必备技能。

2)从小牛通往大牛的必经之路
架构师是软件开发团队中非常重要的角色,成为一名架构师是许多搞技术人奋斗的目标,衡量一个架构师的能力指标就是设计出一套解决高并发的系统,由此可见高并发技术的重要性,而并发编程是底层的基础。无论游戏还是互联网行业,无论软件开发还是大型网站,都对高并发技术人才存在巨大需求,因此,为了工作为了提升自己,学习高并发技术刻不容缓。

3)面试过程中极容易踩坑
面试的时候为了考察对并发编程的掌握情况,经常会考察并发安全相关的知识和线程交互的知识。例如在并发情况下如何实现一个线程安全的单例模式,如何完成两个线程中的功能交互执行。

以下是使用双检索实现一个线程安全的单例懒汉模式,当然也可以使用枚举或者单例饿汉模式。

private static volatile  Singleton singleton;
private Singleton(){};
public Singleton getSingleton(){
    if(null == singleton){
        synchronized(Singleton.class){
            if(null == singleton){
                singleton = new Singleton();
            }
        }
    }
    return singleton;
}

在这里第一层空判断是为了减少锁控制的粒度,使用volatile修饰是因为在jvm中new Singleton()会出现指令重排,volatile避免happens before,避免空指针的问题。从一个线程安全的单例模式可以引申出很多,volatile和synchronized的实现原理,JMM模型,MESI协议,指令重排,关于JMM模型后序会给出更详细的图解。

除了线程安全问题,还会考察线程间的交互。 例如使用两个线程交替打印出A1B2C3…Z26
image.png

考察的重点并不是要简单的实现这个功能,通过此面试题,可以考察知识的整体掌握情况,多种方案实现,可以使用Atomicinteger、ReentrantLock、CountDownLat ch。下图是使用LockSupport控制两个线程交替打印的示例,LockSupport内部实现的原理是使用UNSAFE控制一个信号量在0和1之间变动,从而可以控制两个线程的交替打印。

4)并发在我们工作使用的框架中处处可见,tom cat,netty,jvm,Disruptor

熟悉JAVA并发编程基础是掌握这些框架底层知识的基石,这里简单介绍下高并发框架Disruptor的底层实现原理,做一个勾勒的作用:
Martin Fowler在一篇LMAX文章中介绍,这一个高性能异步处理框架,其单线程一秒的吞吐量可达六百万

Disruptor核心概念
image.png

Disruptor特征

  • 基于事件驱动
  • 基于"观察者"模式、"生产者-消费者"模型
  • 可以在无锁的情况下实现网络的队列操作

RingBuffer执行流程
image.png

Disruptor底层组件,RingBuffer密切相关的对象:Sequ enceBarrier和Sequencer;

SequenceBarrier是消费者和RingBuffer之间的桥梁。在Disruptor中,消费者直接访问的是SequenceBarrier,由SequenceBarrier减少RingBuffer的队列冲突。

SequenceBarrier 通过waitFor方法当消费者速度大于生产者的生产速度时,消费者可通过waitFor方法给予生产者一定的缓冲时间,协调生产者和消费者的速度问题,waitFor执行时机:
image.png

Sequencer是生产者和缓冲区RingBuffer之间的桥梁,生产者通过Sequencer向RingBuffer申请数据存放空间,通过WaitStrategy使用publish方法通知消费者,WaitStrategy是消费者没有数据可以消费时的等待策略。每个生产者或者消费者线程,会先申请可以操作的元素在数组中的位置,申请到之后,直接在该位置写入或者读取数据,整个过程通过原子变量CAS,保证操作的线程安全,这就是Disruptor的无锁设计。

以下是五大常用等待策略:

  • BlockingWaitStrategy:Disruptor的默认策略是BlockingWaitStrategy。在BlockingWaitStrategy内部是使用锁和condition来控制线程的唤醒。BlockingWaitStrategy是最低效的策略,但其对CPU的消耗最小并且在各种不同部署环境中能提供更加一致的性能表现。
  • SleepingWaitStrategy:SleepingWaitStrategy 的性能表现跟 BlockingWaitStrategy 差不多,对 CPU 的消耗也类似,但其对生产者线程的影响最小,通过使用LockSupport.parkNanos(1)来实现循环等待。
  • YieldingWaitStrategy:YieldingWaitStrategy是可以使用在低延迟系统的策略之一。YieldingWaitStrategy将自旋以等待序列增加到适当的值。在循环体内,将调用Thread.yield()以允许其他排队的线程运行。在要求极高性能且事件处理线数小于 CPU 逻辑核心数的场景中,推荐使用此策略;例如,CPU开启超线程的特性。
  • BusySpinWaitStrategy:性能最好,适合用于低延迟的系统。在要求极高性能且事件处理线程数小于CPU逻辑核心数的场景中,推荐使用此策略;例如,CPU开启超线程的特性。

目前,包括Apache Storm、Camel、Log4j2在内的很多知名项目都应用了Disruptor以获取高性能。

5)JUC是并发大神Doug Lea灵魂力作,堪称典范(第一个主流尝试,它将线程,锁和事件之外的抽象层次提升到更平易近人的方式:并发集合, fork/join 等等)

通过并发编程设计思维的学习,发挥使用多线程的优势

  • 发挥多处理器的强大能力
  • 建模的简单性
  • 异步事件的简化处理
  • 响应更灵敏的用户界面

那么学不好并发编程基础会带来什么问题呢

1)多线程在日常开发中运用中处处都是,jvm、tomcat、netty,学好java并发编程是更深层次理解和掌握此类工具和框架的前提由于计算机的cpu运算速度和内存io速度有几个数量级的差距,因此现代计算机都不得不加入一层尽可能接近处理器运算速度的高速缓存来做缓冲:将内存中运算需要使用的数据先复制到缓存中,当运算结束后再同步回内存。如下图:

image.png

因为jvm要实现跨硬件平台,因此jvm定义了自己的内存模型,但是因为jvm的内存模型最终还是要映射到硬件上,因此jvm内存模型几乎与硬件的模型一样:

image.png

操作系统底层数据结构,每个CPU对应的高速缓存中的数据结构是一个个bucket存储的链表,其中tag代表的是主存中的地址,cache line是偏移量,flag对应的MESI缓存一致性协议中的各个状态。

MESI缓存一致性状态分别为:

M:Modify,代表修改

E:Exclusive,代表独占

S:Share,代表共享

I:Invalidate,代表失效

以下是一次cpu0数据写入的流程:

在CPU0执行一次load,read和write时,在做write之前flag的状态会是S,然后发出invalidate消息到总线;

其他cpu会监听总线消息,将各cpu对应的cache entry中的flag状态由S修改为I,并且发送invalidate ack给总线

cpu0收到所有cpu返回的invalidate ack后,cpu0将flag变为E,执行数据写入,状态修改为M,类似于一个加锁过程

考虑到性能问题,这样写入修改数据的效率太过漫长,因此引入了写缓冲器和无效队列,所有的修改操作会先写入写缓冲器,其他cpu接收到消息后会先写入无效队列,并返回ack消息,之后再从无效队列消费消息,采用异步的形式。当然,这样就会产生有序性问题,例如某些entry中的flag还是S,但实际上应该标识为I,这样访问到的数据就会有问题。运用volitale是为了解决指令重排带来的无序性问题,volitale是jvm层面的关键字,MESI是cpu层面的,两者是差了几个层次的。
image.png

2)性能不达标,找不到解决思路。

3)工作中可能会写出线程不安全的方法
以下是一个多线程打印时间的逐步优化案例

new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println(new ThreadLocalDemo01().date(10));
    }
}).start();
new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println(new ThreadLocalDemo01().date(1007));
    }
}).start();

优化1,多个线程运用线程池复用

for(int i = 0; i < 1000; i++){
    int finalI = i;
    executorService.submit(new Runnable() {
        @Override
        public void run() {
            System.out.println(new ThreadLocalDemo01().date2(finalI));
        }
    });
}
executorService.shutdown();

public String date2(int seconds){
        Date date = new Date(1000 * seconds);
        String s = null;
//        synchronized (ThreadLocalDemo01.class){
//            s = simpleDateFormat.format(date);
//        }
        s = simpleDateFormat.format(date);
        return s;
}

优化2,线程池结合ThreadLocal

public String date2(int seconds){
    Date date = new Date(1000 * seconds);
    SimpleDateFormat simpleDateFormat = ThreadSafeFormatter.dateFormatThreadLocal.get();
    return simpleDateFormat.format(date);
}

在多线程服用一个SimpleDateFormat时会出现线程安全问题,执行结果会打印出相同的时间,在优化2中使用线程池结合ThreadLocal实现资源隔离,线程安全。

4)许多问题无法正确定位
踩坑:crm仿真定时任务阻塞,无法继续执行
问题:crm仿真运用schedule配置的定时任务在某个时间节点后的所有定时任务均未执行
原因:定时任务配置导致的问题,@Schedule配置的定时任务如果未配置线程池,在启动类使用@EnableScheduling启用定时任务时会默认使用单线程,后端配置了多定时任务,会出现问题.配置了两定时任务A和B,在A先占用资源后如果一直未释放,B会一直处于等待状态,直到A任务释放资源后,B开始执行,若要避免多任务执行带来的问题,需要使用以下方法配置:

@Bean 
public ThreadPoolTaskScheduler taskScheduler(){ 
  ThreadPoolTaskScheduler scheduler = new       ThreadPoolTaskScheduler(); 
  scheduler.setPoolSize(10); 
  return scheduler; 
}

crm服务由于定时任务配置的不多,并且在资源足够的情况下,任务执行速度相对较快,并未设置定时任务的线程池

定时任务里程序方法如何造成线程一直未释放,导致阻塞。

在问题定位时,产生的问题来自CountDownLatch无法归零,导致整个主线程hang在那里,无法释放。

在api中当调用await时候,调用线程处于等待挂起状态,直至count变成0再继续,大致原理如下:
image.png

因此将目光焦点转移至await方法,使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断或超出了指定的等待时间。如果当前计数为零,则此方法立刻返回true 值。如果当前计数大于零,则出于线程调度目的,将禁用当前线程,且在发生以下三种情况之一前,该线程将一直处于休眠状态:由于调用 countDown() 方法,计数到达零;或者其他某个线程中断当前线程;或者已超出指定的等待时间。

Executors.newFixedThreadPool这是个有固定活动线程数。当提交到池中的任务数大于固定活动线程数时,任务就会放到阻塞队列中等待。CRM该定时任务里为了加快任务处理,运用多线程处理,设置的CountDownLatch的count大于ThreadPoolExecutor的固定活动线程数导致任务一直处于等待状态,计数无法归零,导致主线程一直无法释放,从而导致crm一台仿真服务的定时任务处于瘫痪状态。

03如何学习java并发编程

为了学习好并发编程基础,我们需要有一个上帝视角,一个宏观的概念,然后由点及深,掌握必备的知识点。我们可以从以下两张思维导图列举出来的逐步进行学习。
image.png

必备知识点
image.png

04线程

列举了如此多的案例都是围绕线程展开的,所以我们需要更深地掌握线程,它的概念,它的原则,它是如何实现交互通信的。

以下的一张图可以更通俗地解释进程、线程的区别
image.png

进程: 一个进程好比是一个程序,它是 资源分配的最小单位 。同一时刻执行的进程数不会超过核心数。不过如果问单核CPU能否运行多进程?答案又是肯定的。单核CPU也可以运行多进程,只不过不是同时的,而是极快地在进程间来回切换实现的多进程。电脑中有许多进程需要处于「同时」开启的状态,而利用CPU在进程间的快速切换,可以实现「同时」运行多个程序。而进程切换则意味着需要保留进程切换前的状态,以备切换回去的时候能够继续接着工作。所以进程拥有自己的地址空间,全局变量,文件描述符,各种硬件等等资源。操作系统通过调度CPU去执行进程的记录、回复、切换等等。

线程:线程是独立运行和独立调度的基本单位(CPU上真正运行的是线程),线程相当于一个进程中不同的执行路径。

单线程:单线程就是一个叫做“进程”的房子里面,只住了你一个人,你可以在这个房子里面任何时间去做任何的事情。你是看电视、还是玩电脑,全都有你自己说的算。想干什么干什么,想什么时间做什么就什么时间做什么。

多线程:但是如果你处在一个“多人”的房子里面,每个房子里面都有叫做“线程”的住户:线程1、线程2、线程3、线程4,情况就不得不发生变化了。

在多线程编程中有”锁”的概念,在你的房子里面也有锁。如果你的老婆在上厕所并锁上门,她就是在独享这个“房子(进程)”里面的公共资源“卫生间”,如果你的家里只有这一个卫生间,你作为另外一个线程就只能先等待。
image.png

线程最为重要也是最为麻烦的就是线程间的交互通信过程,下图是线程状态的变化过程:
image.png

为了阐述线程间的通信,简单模拟一个生产者消费者模型:

生产者​​​​​​​

CarStock carStock;
public CarProducter(CarStock carStock){
    this.carStock = carStock;
}

@Override
public void run() {
    while (true){
        carStock.produceCar();
    }
}
public synchronized void produceCar(){
  try {
    if(cars < 20){
      System.out.println("生产者..." + cars);
      Thread.sleep(100);
      cars++;
      notifyAll();
    }else {
      wait();
    }
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
}

消费者

CarStock carStock;
public CarConsumer(CarStock carStock){
    this.carStock = carStock;
}

@Override
public void run() {
    while (true){
        carStock.consumeCar();
    }
}

public synchronized void consumeCar(){
    try {
        if(cars > 0){
            System.out.println("销售车..." + cars);
            Thread.sleep(100);
            cars--;
            notifyAll();
        }else {
            wait();
        }
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

消费过程
image.png

通信过程

对于此简单的生产者消费者模式可以运用队列、线程池等技术对程序进行改进,运用BolckingQueue队列共享数据,改进后的消费过程

image.png

05并发编程三大特性

并发编程实现机制大多都是围绕以下三点:原子性、可见性、有序性

1)原子性问题​​​​​​​

for(int i = 0; i < 20; i++){
    Thread thread = new Thread(() -> {
        for (int j = 0; j < 10000; j++) {
            res++;
            normal++;
            atomicInteger.incrementAndGet();
        }
    });
    thread.start();
}

运行结果:

volatile: 170797
atomicInteger:200000
normal:182406

这就是原子性问题,原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
如果一个操作是原子性的,那么多线程并发的情况下,就不会出现变量被修改的情况。

2)可见性问题​​​​​​​

class MyThread extends Thread{
    public int index = 0;

    @Override
    public void run() {
        System.out.println("MyThread Start");
        while (true) {
            if (index == -1) {
                break;
            }
        }
        System.out.println("MyThread End");
    }
}

main线程将index修改为-1,myThread线程并不可见,这就是可见性问题导致的线程安全,可见性就是指当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方法来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每使用前立即从内存刷新。因为我们可以说volatile保证了线程操作时变量的可见性,而普通变量则不能保证这一点。

3)有序性问题

双检索单例懒汉模式
image.png

有序性: Java内存模型中的程序天然有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存中主内存同步延迟”现象。

06思考题

有时为了尽快释放资源,避免无意义的耗费,会令部分功能提前结束,例如许多抢名额问题,这里出一个思考题供大家参考实现:
题:8人百米赛跑,要求前三名跑到终点后停止运行,设计该问题的实现。

参考资料:
1.亿级流量Java高并发与网络编程实战
2.LMAX文章(http://ifeve.com/lmax/)

下章预告:

  • Volatile和Syncronize关键字
  • Volatile关键字
  • Synchronized关键字Volatile关键字
  • Synchronized关键字

关于好未来技术更多内容请:微信扫码关注「好未来技术」微信公众号
image.png


好未来技术团队
416 声望1.3k 粉丝

好未来作为一家科技驱动的教育企业,始终坚持“爱和科技让教育更美好”的使命。