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

2018-04-19  本文已影响0人  Lynn_R01612x2

一、概述

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. 执行策略

执行策略:

执行策略的目的:

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

执行策略的优势:

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

2. 线程管理

ThreadExecutor线程池 - 重用已有线程

线程池注意点:

3. 中断、取消和关闭

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

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

取消策略(how、when、what)

六、扩展

1. java内存模型

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

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

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

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

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

20160919130233191.jpg

2. 指令重排

目标:提高运行速度。

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

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

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

猜你喜欢

热点阅读