并发编程

Posted 东瓜

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程相关的知识,希望对你有一定的参考价值。

并发编程

实践场景

  • 怎么防重复提交

    • 定义业务唯一ID
    • 操作前使用唯一ID做key设置分布式锁
    • 先查后插,做业务幂等控制
    • 设置数据库唯一键
    • 使用token机制,设置token一次有效
  • 业务幂等怎么做

    • 定义业务唯一ID
    • 操作前使用唯一ID做key设置分布式锁
    • 定义数据库唯一键
    • 用唯一键先查,有则直接返回,无则处理。如商户下订单、发优惠券,使用商户订单id先查这边的订单
    • 定义状态流转。如订单状态为已支付,则不能再发起支付
  • 并发扣库存&秒杀场景

    • 客户端限制按钮点击频率
    • 服务端通过ng限制用户请求频率
    • 缓存

      • 热点数据缓存
      • 缓存预热
    • 异步

      • 库存提前加载到redis,抢购成功后立即返回,通过消息队列异步处理后续步骤,扣减数据库库存、发送消息等
    • NG用令牌桶算法对接口限流
    • 服务端可以根据库存情况或数据库的抗压能力,对请求进行令牌桶限制,或使用队列进行削峰限速
    • 一些非主要业务服务可以考虑先降级
    • 动静分离,CDN加速
  • 服务器响应慢的原因

    • 外部资源

      • 请求外部资源阻塞或响应变慢,数据库、redis、外部接口等
    • 做活动等引起的流量突发,导致请求排队
    • 自身

      • 死锁、死循环导致应用cpu飙高
      • 频繁fullgc导致应用响应慢
    • 网络抖动
  • 接口、服务器响应慢怎么定位

    • pinpoint等链路追踪工具查看接口各阶段执行耗时可以很容易定位到接口
    • 看错误日志
    • 看线程情况
    • 查看内存占用情况
    • 查看应用gc日志,检查fullgc频率是否正常,gc异常可使用jmap打印堆转储dump文件,看哪些对象占用内存多
    • 使用top-c命令查看进程cpu占用情况,cpu占用高考虑为死锁或死循环导致,定位到占用cpu的线程号
    • 使用jstack命令打出线程堆栈,查看对应线程运行情况(线程状态、线程等待的锁等)
    • 开一台实例的debug日志,使用awk命令筛选出耗时长的请求traceId,分析请求日志定位耗时操作
    • 检查调用关联方接口的耗时情况
    • 检查数据库、redis等运行情况,redisCPU飙高也会导致响应慢
    • 检查网络情况(sar命令等,运维操作)

多线程

基础知识

线程与进程的区别

  • 进程是操作系统资源分配的基本单位,一个进程就是一个程序,有自己独立的内存空间
  • 线程是处理器任务调度和执行的基本单位
  • 一个进程可以包含多个线程

并行与并发的区别

  • 并发是一个CPU按分配的时间片轮流处理多个任务,从逻辑上看任务是同时执行的
  • 并行是多个CPU同时处理多个任务,是真正意义上的同时进行

使用多线程的好处与弊端

好处:

  • 充分利用CPU的计算能力
  • 一条线程I/O或阻塞时,CPU可切换执行另外一条线程的计算工作
  • 能提高程序的执行效率,提高程序的运行速度

弊端:

  • 需要占用更多资源,每条线程都需要在栈内分配空间
  • 需要考虑线程安全问题,死锁等情况
  • 大量线程上下文切换,带来的性能损耗

多线程使用场景

  • 异步处理任务。当接口需要处理一个耗时操作并且不需要立刻知道处理结果时,使用多线程异步处理减少接口响应时间。
  • 并行处理。对一些耗时长的接口,通过多线程并行处理给主线程返回future的方式,加快接口处理速度。
  • 处理后台任务。后台定时任务线程处理一些数据修改操作等。

java线程状态

  • NEW,新建。刚创建出线程实例,一旦调用thread.start(),线程状态将会变成runnable。
  • RUNNABLE,可运行状态。线程正在运行,或等待分配CPU,或等待IO事件完成。
  • BLOCKED,阻塞。线程进入synchronized修饰的代码块前未获取到锁。
  • WAITING,等待。在同步代码块中调用wait方法,LockSupport.park线程,Thread.join等待线程同步
  • TIMED_WAITING,定时等待。同上,在等待方法中加入时间参数
  • TERMINATED。终止状态,线程执行完成,或执行异常且没有进行捕获处理

操作系统线程状态(新建、就绪、运行、等待、结束)

  • 初始状态。线程刚被创建,还不能分配CPU。
  • 可运行状态。线程等待系统分配CPU,从而执行任务。
  • 运行状态。操作系统将CPU分配给线程,线程执行任务。
  • 休眠状态。线程调用阻塞API,如阻塞方式读取文件,休眠状态的线程将让出CPU。
  • 终止状态。线程执行完,或执行过程中发生异常。

java线程状态与操作系统状态的异同

  • java的RUNNABLE状态包含操作系统的可运行、运行、休眠状态
  • java线程BLOCKED、WAITING,TIMED_WAITING对应操作系统休眠状态

线程间通信

  • 在同步代码块中使用notify,notifyAll方法通信
  • 通过LockSupport.park与unPark通信
  • 通过共享变量通信
  • 通过interrupt通信

线程上下文切换内容

  • 线程上下文是指某一时间点CPU寄存器和程序计数器的内容,CPU通过时间片分配算法来循环执行任务(线程),因为时间片非常短,所以CPU通过不停地切换线程执行。
  • 具体包括CPU寄存器和程序计数器的内容、用户态与内核态之间的切换

减少线程上下文切换的方法

  • 无锁并发编程:就是多线程竞争锁时,会引起上下文切换,多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  • CAS算法:Java的Atomic包使用CAS算法来更新数据,就是它在没有锁的状态下,可以保证多个线程对一个值的更新。
  • 使用最少线程:避免创建不需要的线程。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

创建线程

创建线程的四种方式

  • 继承 Thread 类;
  • 实现 Runnable 接口;
  • 实现 Callable 接口;

    • 可用FutureTask对象接收线程执行的结果
    • 可以捕获线程执行的异常
  • 使用 Executors 工具类创建线程池

run()和 start()的区别

  • start()方法用于启动线程,run()方法用于执行线程的运行时代码,只是方法体中的一个普通函数
  • start()方法会启动一个线程,并执行相应准备工作,然后调用run()方法,run()方法中执行真正的多线程业务逻辑

线程安全

并发编程三要素

线程的安全性问题体现在:

原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)

出现线程安全问题的原因:

线程切换带来的原子性问题
缓存导致的可见性问题
编译优化带来的有序性问题

解决办法:
JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
synchronized、volatile、LOCK,可以解决可见性问题
Happens-Before 规则可以解决有序性问题

关键字

volatile

volatile关键字的作用
对于可见性,Java 提供了 volatile 关键字来保证可见性和禁止指令重排。 volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

synchronized关键字

描述

synchronized关键字是用来控制线程同步的,它的作用是在多线程环境下,保证被synchronized关键字修饰的代码在同一时间只能被一个线程执行

实现原理

synchronized的实现依赖虚拟机,通过获取和释放锁对象的管程(Monitor)对象实现
Monitor对象是一种同步工具,包含一个对象和多个队列,控制对象的获取和挂起线程

synchronized优化

锁消除、锁粗化
默认打开适用性自旋锁
锁升级

synchronized的执行过程(锁升级过程)
1.检测锁对象头的MarkWord里面是不是当前线程ID,如果是,表示当前线程处于偏向锁
2.如果不是,则使用CAS将MarkWord里面的线程ID替换为当前线程,成功则表示获取到偏向锁
3.失败则说明发生竞争,撤销偏向锁,升级为轻量级锁
4.当前线程使用CAS将对象头的MarkWord替换为锁记录指针,如果成功,当前线程获得锁
5.如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
6.如果自旋成功则依然处于轻量级状态
7.如果自旋失败,则升级为重量级锁

synchronized的锁升级性能优化在哪:
偏向锁:单线程无竞争情况访问同步块时,只需让锁对象的对象头记录当前线程ID,不需要触发同步的原生语意-管程机制,不需要获取Monitor对象
轻量级锁:通过自旋来减少挂起线程的操作,如果同步代码块的执行时间较短,线程自旋后可能获取锁

线程池

线程池的核心参数

线程池的扩张和回收策略

线程池线程数怎么设置

  • 线程数的设置根据具体业务情况而定,我们会评估业务的一般并发情况,来设置线程池的coreSize参数,同时会将线程池的maxSize参数设置为coreSize的5到10倍
  • 核心线程数设置看执行任务的类型,CPU密集型任务一般设置CPU核数加1,IO密集型任务线程数会适当增加,根据一个CPU计算耗时与IO耗时的比例公式设置(计算时间+IO时间)/计算时间*CPU核数
  • 后续根据业务的实际运行情况进行调整

java锁

  • 锁的分类
    *悲观锁
    *乐观锁

    *共享锁
    *排他锁

    *可重入锁
    *非可重入锁

    *公平锁
    *非公平锁

  • 乐观锁的ABA问题

    • 通过递增版本号解决,cas时比较原值与版本号
  • 死锁

    • 产生的原因
      线程获取两个以上的互斥锁,线程A获取锁A等待锁B,线程B获取锁B等待锁A
    • 解决办法

      • 以确定的顺序获得锁。针对两个特定的锁,可以按照锁对象的hashCode值大小的顺序,分别获得两个锁
      • 超时放弃。在获取锁超时以后,主动释放之前已经获得的所有的锁

AQS

AQS(AbstractQueuedSynchronizer)是RetrentLock与Java并发包工具的实现基础,其底层采用乐观锁,大量使用了CAS操作,并且在冲突时,采用自旋方式重试,以实现轻量级锁和高效的获取锁

CAS
CAS的全称为Compare-And-Swap,是一条CPU的原子指令,其作用是让CPU比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用

其实现主要由状态、队列、CAS三部分组成:
状态:在AQS中,状态由state属性来表示,该属性的值表示了锁的状态,state为0表示锁没有被占用,state大于0表示已被占用(状态可以大于1,以实现可重入)
队列:在AQS中,队列的实现是一个双向链表,它表示所有等待锁的线程的集合,当线程获取锁失败时通过CAS操作将自己加入队列的末尾
CAS操作:操作系统层面提供的API,CAS是一条CPU原语,其包含三个操作数——内存位置、预期原值和新值,如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值

执行流程

  • 线程尝试获取锁,将state的状态通过CAS操作由0改写成1
  • 设置成功,将当前获取到锁的线程设置为自己
  • 未获取到锁,则将自己封装成node,通过CAS操作加入队列尾部
  • 如果前置节点是头节点,则会再次尝试获取锁
  • 如果自旋获取锁失败,将前驱节点状态设置为signal后,通过LockSupport.park,挂起当前线程
  • 拿到锁的线程执行完逻辑后,通过LockSupport.unPark唤起后置节点线程,后置节点线程则会再次尝试去获取锁

AQS超时机制
通过LockSupport的parkNanos实现

并发容器

ConcurentHashMap实现

jdk1.7

  • 使用一个Segment数组和多个HashEntry组成,每个数组桶位下面挂一个HashEntry
  • 当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,再通过ReentrantLock进行加锁后,将数据添加HashEntry中的对应位置

jdk1.8

  • 与HashMap结构一致,底层是数组+链表或数组加红黑树结构实现
  • 作流程是,对key进行hash运算定位到数组下标,如果下标位置链表为空则先初始化,再cas插入,如果有数据,则用同步锁Synchronized对数组桶位进行加锁后插入

这个改动说明了jdk团队认为经过优化后的Synchronized比ReentrantLock效率高

ThreadLocal

ThreadLocal是一个线程隔离变量
ThreadLocal.set方法相当于给当前线程的TheadLocalMap属性赋值,key就是TheadLocal自己,value是要set的值

使用场景:

  • 多线程使用一些线程不安全的工具类时,ThreadLocal复用工具对象
  • 用来传递上下文,可以避免对象的跨层传递。(定义一个工具类,类中定义一个TheadLocal静态属性,通过工具类调用TheadLocal的set和get方法)
  • 具体场景:之前oracle迁移mysql数据库时,切换开关使用ThreadLocal保存至当前线程,避免开关值的上下文传递

内存泄露场景:
由于TheadLocalMap与线程生命周期一致,在使用线程池等线程生命周期长的场景使用TheadLocal时,如不手动执行remove操作,value便不会被回收,造成内存泄露

HashMap

  • 当链表节点数大于8时会转换为红黑树,小于6转回链表
  • 对key进行hash运算时进行了两次hash运算,并有扰流函数,减少hash冲突,可以均匀分布
  • 位与操作与取模
    由于位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
    正整数对2的倍数取模,只要将数与2的倍数-1做按位与运算
    对2的倍数取余,只要将数右移2的倍数位

以上是关于并发编程的主要内容,如果未能解决你的问题,请参考以下文章

golang代码片段(摘抄)

《java并发编程实战》

Java并发编程实战 04死锁了怎么办?

Java并发编程实战 04死锁了怎么办?

Java编程思想之二十 并发

golang goroutine例子[golang并发代码片段]