JAVA多线程·并发问题及解决思路

一、概述

1. 线程

线程允许在同一个进程中存在多个程序控制流。线程可以共享进程的资源,但是每个线程都有自己的程序计数器、栈和局部变量表。同一进程中的不同线程能够访问相同的变量,并且在同一个堆上分配对象。

2. 多线程

多线程的优势/作用

  • 提高程序的运行性能。
  • 充分利用系统的处理能力,提高系统的资源利用率。
  • 提高系统响应性,即线程可以在运行现有任务的情况下立即开始处理新的任务。

多线程通信

多线程之间需要进行通信,线程的通信依赖共享内存和线程方法的调用来实现。Java内存模型分为主内存和工作内存,通过内存之间的数据交换实现线程之间的通信;主动调用线程的wait()、notify()方法也可以实现线程之间的通信。

多线程引发的问题

多线程并发执行可能会导致一些问题:

安全性问题:在单线程系统上正常运行的代码,在多线程环境中可能会出现意料之外的结果。

活跃性问题:不正确的加锁、解锁方式可能会导致死锁or活锁问题。

性能问题:多线程并发即多个线程切换运行,线程切换会有一定的消耗并且不正确的加锁。

名词概念:

  • 安全性:即正确性,指“程序得到正确的结果”。
  • 活跃性:指”正确的是最终会发生“。
  • 性能:即程序的服务时间、延迟时间(响应速度)、吞吐率、可伸缩性、容量、效率等。

多线程问题的深层原因

  1. 分时调度模型
  1. JAVA内存模型
  2. 指令重排

二、并发问题(安全性问题)

核心

要编写线程安全的代码,核心在于对状态访问操作进行管理。特别是对共享的和可变的状态的访问。

解决思路

当发生安全性问题时。有三种解决问题的角度:

  • 不在线程之间共享变量
  • 将状态变量修改为不可变
  • 访问状态变量时使用同步机制。

前两种方式从根本上避免了多线程并发问题的原因:对共享和可变状态的访问。

1. 不在线程之间共享变量

即限制变量只能在单个线程中访问。

实现方式:

  1. 线程封闭

    保证变量只能被一个线程可以访问到。可以通过Executors.newSingleThreadExecutor()实现。

  2. 栈封闭

    栈封闭即使用局部变量。局部变量只会存在于本地方法栈中,不能被其他线程访问,因此也就不会出现并发问题。所以如果可以使用局部变量就优先使用局部变量。

  3. ThreadLocal封闭

    ThreadLocal是Java提供的实现线程封闭的一种方式,ThreadLocal内部维护了一个Map,Map的key是各个线程,而Map的值就是要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal利用Map实现了对象的线程封闭。

2. 将状态变量修改为不可变

即使用不可变对象。

不可变对象:当一个对象构造完成后,其状态就不再变化,我们称这样的对象为不可变对象(Immutable Object),这些对象关联的类为不可变类(Immutable Class)。

比如Java中的String、Integer、Double、Long等所有原生类型的包装器类型,都是不可变的。

大多数时候,线程间是通过使用共享资源实现通信的。如果该共享资源诞生之后就完全不再变更(犹如一个常量),多线程间共同并发读取该共享资源是不会产生线程冲突的,因为所有线程无论何时读取该共享资源,总是能获取到一致的、完整的资源状态,这样也能规避多线程冲突。不可变对象就是这样一种诞生之后就完全不再变更的对象,该类对象天生支持在多线程间共享。

3. 使用同步机制

关注一个并发问题,有3个基本的关注点:

  • 原子性,即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 可见性,当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性,有序性指的是数据不相关的变量在并发的情况下,实际执行的结果和单线程的执行结果是一样的,不会因为重排序的问题导致结果不可预知。

所有的并发问题的都可以从这三个点进行分析并针对性的进行解决。

a. 可见性问题

概念:

可见性指的是一个线程对变量的写操作对其他线程后续的读操作可见

问题分析:

由于Java的内存模型,内存分为主内存和线程内存,线程读写变量时都需要先讲主内存的变量拷贝到线程内存中,读写操作都在线程内存中进行。可能一个线程写入了变量的值,但还没有同步到主内存中,这时另外一个线程读取变量值就会读到旧的值,即发生了可见性问题。

解决方式:voliate关键字/synchronized关键字

原理::每次修改变量后,立即将变量写会主内存;每次使用变量时,必须从主内存中同步变量的值。

b.原子性问题

概念:

原子性是指某个(些)操作在语意上是原子的。

问题分析:

由于线程的调度模型,每个线程会被分配一定的cpu时间,执行时间结束后切换到下一个线程执行。可能存在一个线程访问并修改变量,在整个操作完成之前被切出执行,切换到另外一个线程执行,该线程对变量也进行的操作,之后再切回原来线程继续执行时,变量的值可能已经被修改,无法得到正确的结果。因此需要通过某些方式保证操作的原子性。

原子性变量操作:

根据Java内存模型保证的原子性变量操作包括read load use assign store write,基础数据类型的访问、读取、写是原子性的(注意long,double),另外还有lock和unlock操作,反映到java代码中即synchronized操作也是原子性的

解决方式:synchronized关键字/其他互斥锁

原理:保证同一时间只能有一个线程访问加锁区域中的代码,即保证了原子性。

扩展

竞态条件:指某个操作由于不同的执行时序而出现不同的结果。专门用来描述原子性问题。

c. 有序性问题

有序性的语意有三层

  1. 保证多线程执行的串行顺序
  2. 防止重排序引起的问题
  3. 程序执行的先后顺序,比如JMM定义的一些Happens-before规则

解决方式:volatile, final, synchronized,显式锁都可以保证有序性。

三、活跃性问题

活跃性问题包括但不限于死锁、活锁、饥饿等。

死锁:死锁发生在一个线程需要获取多个资源的时候,这时由于两个线程互相等待对方的资源而被阻塞,死锁是最常见的活跃性问题。

活锁:活锁指的是线程不断重复执行相同的操作,但每次操作的结果都是失败的。尽管这个问题不会阻塞线程,但是程序也无法继续执行。

饥饿:饥饿指的线程无法访问到它需要的资源而不能继续执行时,引发饥饿最常见资源就是CPU时钟周期。

四、性能问题

1. 性能

概述

性能包括很多方面:服务时间、延迟时间(响应速度)、吞吐率、可伸缩性、容量、效率等。

分类

可以分为两大类:

运行速度:服务时间、等待时间。即对于某个指定的任务单元,“多快”才能处理完成。(一般对于单线程)

处理能力:可伸缩性、吞吐量、生产量。即对于计算资源一定的情况下,可以完成“多少”工作。(一般针对于多线程)

性能调优

在进行性能调优时需要明确优化指标、运行环境、测试or验证方式、优化的代价和影响等多方面因素。

需要考虑多种修改可能造成的影响:安全性、可读性、可维护性、资源消耗、其他风险等。

针对线程并发进行设计和优化时采用的方法和传统的性能调优方法不同。

对于传统的性能调优:已更少的代价完成相同的工作,比如缓存、替换使用低复杂度算法等。

对于并发的性能调优:将问题的计算并行化、从而利用更多的计算资源。

2. 针对并发程序的性能调优

多线程的最主要目的是提高程序的运行性能。使程序充分利用系统的处理能力,提高系统的资源利用率。

在讨论并发程序的性能时,一般关注它的可伸缩性。

可伸缩性:当增加计算资源时(CPU、内存、存储容量或I/O带宽),程序的吞吐量或处理能力能够相应的增加。

想要通过线程并发获得更好的伸缩性有两个关键点:

  1. 有效利用现有的处理资源
  2. 在出现新的系统资源时使程序尽可能利用这些新资源

系统资源:CPU时钟周期、内存、网络带宽、I/O带宽、数据库请求、磁盘空间等。

可伸缩性优化的注意点:

  • 并发中的串行操作
  • 线程的额外开销,包括上下文切换、内存同步、阻塞等
  • 独占方式的资源锁,当锁的请求频率*持有时间越大时表明锁的竞争越激烈。可以通过缩小锁的范围(持有时间)、锁分解(多加锁、但是会增加死锁的概率)、锁分段、避免热点域、减少使用独占锁等方式来减少锁的竞争。

五、并发程序的设计

1. 执行策略

执行策略:

  • 在什么线程中执行任务
  • 以什么顺序执行这些策略,FIFO、LIFO、优先级
  • 有多少个任务可以并发执行
  • 有多少个任务可以等待执行
  • 如果系统因为过载需要抛弃一个任务,应该选择哪一个任务来抛弃?进一步,如何通知应用程序任务被抛弃?

执行策略的目的:

更高效地利用系统资源,提高服务质量。避免因为并发影响了性能。

执行策略的优势:

将任务的提交过程和执行过程解耦。

2. 线程管理

ThreadExecutor线程池 - 重用已有线程

  • 减少系统开销,减少线程创建和销毁时的巨大开销
  • 提高响应性,避免由于创建线程导致延迟任务执行的时机
  • 充分利用系统资源,创建足够多的线程使处理器保持忙碌状态,同时防止多个线程竞争资源导致应用程序耗尽内存或者失败

线程池注意点:

  • 任务和执行策略之间的耦合性和相关性
  • 线程池大小
  • 配置ThreadPoolExecutor:线程的创建和销毁、管理任务队列、饱和策略、线程工厂
  • 其他扩展:日志、计时、监视、统计信息

3. 中断、取消和关闭

线程的取消和关闭,可以分为四个维度:任务、线程、服务、应用程序

针对任务,需要明确取消策略:

取消策略(how、when、what)

  • 其他代码如何请求取消该任务
  • 任务在何时取消检查已经请求了取消
  • 响应取消请求时需要应该进行哪些操作

六、扩展

1. java内存模型

java内存模型主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

Java内存模型分为主内存和工作内存。驻内存所有线程共享,工作内存每个线程单独拥有,不共享。

线程工作内存中保存着该线程所用到的变量的主内存副本拷贝。

线程对于变量的所有操作,都必须在工作内存中进行,不能直接读写主内存中的变量。

线程无法获取其他线程工作内存中的变量,线程间变量值的传递必须通过主内存完成

  • lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
  • unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中

 

2. 指令重排

目标:提高运行速度。

起因:只要程序的最终结果与严格串行环境中执行的结果相同,那么所有操作都是允许的。

重排序的问题是一个单独的主题,常见的重排序有3个层面:

  1. 编译级别的重排序,比如编译器的优化
  2. 指令级重排序,比如CPU指令执行的重排序
  3. 内存系统的重排序,比如缓存和读写缓冲区导致的重排序

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值