JAVA如何解决并发问题

并发问题的根源在哪

首先,我们要知道并发要解决的是什么问题?并发要解决的是单进程情况下硬件资源无法充分利用的问题。而造成这一问题的主要原因是CPU-内存-磁盘三者之间速度差异实在太大。如果将CPU的速度比作火箭的速度,那么内存的速度就像火车,而最惨的磁盘,基本上就相当于人双腿走路。

这样造成的一个问题,就是CPU快速执行完它的任务的时候,很长时间都会在等待磁盘或是内存的读写。

计算机的发展有一部分就是如何重复利用资源,解决硬件资源之间效率的不平衡,而后就有了多进程,多线程的发展。并且演化出了各种为多进程(线程)服务的东西:

  • CPU增加缓存机制,平衡与内存的速度差异
  • 增加了多个概念,CPU时间片,程序计数器,线程切换等,用以更好得服务并发场景
  • 编译器的指令优化,希望在内部充分利用硬件资源

但是这样一来,也会带来新的并发问题,归结起来主要有三个。

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

我们分别介绍这几个:

缓存导致的可见性

CPU为了平衡与内存之间的性能差异,引入了CPU缓存,这样CPU执行指令修改数据的时候就可以批量直接读写CPU缓存的内存,一个阶段后再将数据写回到内存。

但由于现在多核CPU技术的发展,各个线程可能运行在不同CPU核上面,每个CPU核各有各自的CPU缓存。前面说到对变量的修改通常都会先写入CPU缓存,再写回内存。这就会出现这样一种情况,线程1修改了变量A,但此时修改后的变量A只存储在CPU缓存中。这时候线程B去内存中读取变量A,依旧只读取到旧的值,这就是可见性问题。

线程切换带来的原子性

为了更充分得利用CPU,引入了CPU时间片时间片的概念。进程或线程通过争用CPU时间片,让CPU可以更加充分得利用。

比如在进行读写磁盘等耗时高的任务时,就可以将宝贵的CPU资源让出来让其他线程去获取CPU并执行任务。

但这样的切换也会导致问题,那就是会破坏线程某些任务的原子性。比如java中简单的一条语句count += 1。

映射到CPU指令有三条,读取count变量指令,变量加1指令,变量写回指令。虽然在高级语言(java)看来它就是一条指令,但实际上确是三条CPU指令,并且这三条指令的原子性无法保证。也就是说,可能在执行到任意一条指令的时候被打断,CPU被其他线程抢占了。而这个期间变量值可能会被修改,这里就会引发数据不一致的情况了。所以高并发场景下,很多时候都会通过锁实现原子性。而这个问题也是很多并发问题的源头。

编译器优化带来的有序性

因为现在程序员编写的都是高级语言,编译器需要将用户的代码转成CPU可以执行的指令。

同时,由于计算机领域的不断发展,编译器也越来越智能,它会自动对程序员编写的代码进行优化,而优化中就有可能出现实际执行代码顺序和编写的代码顺序不一样的情况。

而这种破坏程序有序性的行为,在有些时候会出现一些非常微妙且难以察觉的并发编程bug。

举个简单的例子,我们常见的单例模式是这样的:

public class Singleton {

 private Singleton() {}

 private static Singleton sInstance;

 public static Singleton getInstance() {

  if (sInstance == null) {	//第一次验证是否为null
   synchronized (Singleton.class) {  //加锁
    if (sInstance == null) {	 //第二次验证是否为null
     sInstance = new Singleton(); //创建对象
         }
       }
     }
  return sInstance;
  }

}

即通过两段判断加锁来保证单例的成功生成,但在极小的概率下,可能会出现异常情况。原因就出现在sInstance = new Singleton();这一行代码上。这行代码,我们理解的执行顺序应该是这样:

  1. 为Singleton象分配一个内存空间。
  2. 在分配的内存空间实例化对象。
  3. 把Instance 引用地址指向内存空间。

但在实际编译的过程中,编译器有可能会帮我们进行优化,优化完它的顺序可能变成如下:

  1. 为Singleton对象分配一个内存空间。
  2. 把instance 引用地址指向内存空间。
  3. 在分配的内存空间实例化对象。

按照优化完的顺序,当并发访问的时候,可能会出现这样的情况

  1. A线程进入方法进行第1次instance == null判断。
  2. 此时A线程发现instance 为null 所以对Singleton.class加锁。
  3. 然后A线程进入方法进行第2次instance == null判断。
  4. 然后A线程发现instance 为null,开始进行对象实例化。
  5. 为对象分配一个内存空间。
  6. .把Instance 引用地址指向内存空间(而就在这个指令完成后,线程B进入了方法)。
  7. B线程首先进入方法进行第1次instance == null判断。B线程此时发现instance 不为null ,所以它会直接返回instance (而此时返回的instance 是A线程还没有初始化完成的对象)

最终线程B拿到的instance 是一个没有实例化对象的空内存地址,所以导致instance使用的过程中造成程序错误。解决办法很简单,可以给sInstance对象加上一个关键字,volatile,这样编译器就不会乱优化,有关volatile的具体内容后续再细说。

主要解决办法

通过上面的介绍,其实可以归纳无论是CPU缓存,线程切换还是编译器优化乱序,出现问题的核心都是因为多个线程要并发读写某个变量或并发执行某段代码。那么我们可以控制,一次只让一个线程执行变量读写就可以了,这就是互斥。

而在某些时候,互斥还不够,还需要一定的条件。比如一个生产者一个消费者并发,生产者向队列存东西,消费者向队列拿东西。那么生产者写的时候要保证存的时候队列不是满的,消费者要保证拿的时候队列非空。这种线程与线程间需要通信协作的情况,称为同步,同步可以说是更复杂的互斥。

既然知道了并发编程的根源以及同步和互斥,那我们来看看有哪些解决的思路。其实一共也就三种:

  • 避免共享
  • Immutability(不变性)
  • 管程及其他工具

下面我们分别说说这三种方案的优缺点

避免共享

我们先来说说避免共享,其实避免共享说是线程本地存储技术,在java中指的一般就是Threadlocal。ThreadLocal会为每个线程提供一个本地副本,每个线程都只会修改自己的ThreadLocal变量。这样一来就不会出现共享变量,也就不会出现冲突了。

其实现原理是在ThreadLocal内部维护一个ThreadLocalMap,每次有线程要获取对应变量的时候,先获取当前线程,然后根据不同线程取不同的值,典型的以空间换时间。

所以ThreadLocal还是比较适用于需要共享资源,且资源占用空间不大的情况。比如一些连接的session啊等等。但是这种模式应用场景也较为有限,比如需要同步情况就难以胜任。

Immutability(不变性)

Immutability在函数式中用得比较多,函数式编程的一个主要目的是要写出无副作用的代码,有关什么是无副作用可以参考我以前的文章Scala函数式编程指南(一) 函数式思想介绍。而无副作用的一个主要特点就是变量都是Immutability即不可变的,即创建对象后不会再修改对象,比如scala默认的变量和数据结构都是不可变的。而在java中,不变性变量即通过final修饰的变量,如String,Long,Double等类型都是Immutability的,它们的内部实现都是基于final关键字的。

那这又和并发编程有什么关系呢?其实啊,并发问题很大部分原因就是因为线程切换破坏了原子性,这又导致线程随意对变量的读写破坏了数据的一致性。而不变性就不必担心这个问题,因为变量都是不变,不可写只能读的。在这种编程模式下,你要修改一个变量,那么只能新生成一个。这样做的好处很明显,但坏处也是显而易见,那就是引入了额外的编程复杂度,丧失了代码的可读性和易用性。

因为如此,不变性的并发解决方案其实相对而已没那么广泛,其中比较有代表性的算是Actor并发编程模型,我以前也有讨论过,有兴趣可以看看Actor模型浅析 一致性和隔离性,这种编程模型和常规并发解决方案有很显著的差异。按我的了解,Acctor模式多用在分布式系统的一些协调功能,比如维持集群中多个机器的心跳通信等等。如果在单机并发环境下,还是下面要介绍的管程类工具才是利器。

管程及其他工具

其实最早的操作系统中,解决并发问题用的是信号量,信号量通过两个原子操作wait(S),和signal(S)(俗称P,V操作)来实现访问资源互斥和同步。比如下面这个小例子:

//整型信号量定义
int S;

//P操作
wait(S){
 while(S<=0);
 S--;
}

//V操作
signal(S){
 S++;
}

虽然信号量方便有效,但信号量要对每个共享资源都实现对应的P和V操作,这使得并发编程中可能要出现大量的P,V操作,并且这部分内容难以抽象出来。

为了更好地实现同步互斥,于是就产生了管程(即Monitor,也有翻译为监视器),值得一提的是,管程也有几种模型,分别是:Hasen模型,Hoare模型和MESA模型。其中MESA模型应用最广泛,java也是参考自MESA模型。这里简单介绍下管程的理论知识,这部分内容参考自进程同步机制-----为进程并发执行保驾护航,希望了解更多管程理论知识的童鞋可以看看。

我们来通过一个经典的生产-消费队列来解释,如下图

我们先解释下图中右半部分的内容,右上角有一个等待调用的线程队列,管程中每次只能有一个线程在执行任务,所以多个任务需要等待。然后是各个名词的意思,生产-消费需要往队列写入和取出东西,这里的队列就是共享变量,对共享资源进行操作称之为过程(入队和出队两个过程)。而向队列写入和取出是有条件的,写入的时候队列必须是非满的,取出的时候队列必须是非空的,这两个条件被称为条件变量

然后再来看看左半部分的内容,假设线程T1读取共享变量(即队列),此时发现队列为空(条件变量之一),那么T1此时需要等待,去哪里等呢?去条件变量队列不能为空对应的队列中去等待。此时另一个线程T2向共享变量队列写数据,通过了条件变量队列不能满,那么写完后就会通知线程T1。但因为管程的限制,管程中只能有一个线程在执行,所以T1线程不能立即执行,它会回到右上角的线程等待队列等待(不同的管程模型在这里是有分歧的,比如Hasen模型是立即中断T2线程让队列中下一个线程执行)。

解释完这个图,管程的概念也就呼之欲出了,

hansen对管程的定义如下:一个管程定义了一个数据结构和能力为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。

本质上,管程是对共享资源以及对共享资源的操作抽象成变量和方法,要操作共享变量仅能通过管程提供的方法(比如上面的入队和出队)间接访问。所以你会发现管程其实和面向对象的理念是十分相近的,在java中,主要提供了低层次了synchronized关键字和wait(),notify()等方法。同时还提供了高层次的ReenTrantLock和Condition来实现管程模型。

以上~

(0)

相关推荐

  • Java并发的CAS原理与ABA问题的讲解

    CAS原理 在计算机科学中,比较和交换(Compare And Swap)是用于实现多线程同步的原子指令. 它将内存位置的内容与给定值进行比较,只有在相同的情况下,将该内存位置的内容修改为新的给定值. 这是作为单个原子操作完成的. 原子性保证新值基于最新信息计算; 如果该值在同一时间被另一个线程更新,则写入将失败. 操作结果必须说明是否进行替换; 这可以通过一个简单的布尔响应(这个变体通常称为比较和设置),或通过返回从内存位置读取的值来完成(摘自维基本科) CAS流程 以AtomicIntege

  • Java并发编程this逃逸问题总结

    this逃逸是指在构造函数返回之前其他线程就持有该对象的引用. 调用尚未构造完全的对象的方法可能引发令人疑惑的错误, 因此应该避免this逃逸的发生. this逃逸经常发生在构造函数中启动线程或注册监听器时, 如: public class ThisEscape { public ThisEscape() { new Thread(new EscapeRunnable()).start(); // ... } private class EscapeRunnable implements Run

  • java并发访问重复请求过滤问题

    问题描述 前段时间遇到个问题,自己内部系统调用出现重复请求导致数据混乱. 发生条件:接受到一个请求,该请求没有执行完成又接受到相同请求,导致数据错误(如果是前一个请求执行完成,马上又接受相同请求不会有问题) 问题分析:是由于数据库的脏读导致 问题解决思路 1.加一把大大的锁 (是最简单的实现方式,但是性能堪忧,而且会阻塞请求) 2.实现请求拦截 (可以共用,但是怎么去实现却是一个问题,怎么用一个优雅的方式实现,并且方便复用) 3.修改实现 (会对原有代码做改动,存在风险,最主要的是不能共用) 最

  • 详解java解决分布式环境中高并发环境下数据插入重复问题

    java 解决分布式环境中 高并发环境下数据插入重复问题 前言 原因:服务器同时接受到的重复请求 现象:数据重复插入 / 修改操作 解决方案 : 分布式锁 对请求报文生成 摘要信息 + redis 实现分布式锁 工具类 分布式锁的应用 package com.nursling.web.filter.context; import com.nursling.nosql.redis.RedisUtil; import com.nursling.sign.SignType; import com.nu

  • java并发问题概述

    1什么是并发问题. 多个进程或线程同时(或着说在同一段时间内)访问同一资源会产生并发问题. 银行两操作员同时操作同一账户就是典型的例子.比如A.B操作员同时读取一余额为1000元的账户,A操作员为该账户增加100元,B操作员同时为该账户减去50元,A先提交,B后提交.最后实际账户余额为1000-50=950元,但本该为1000+100-50=1050.这就是典型的并发问题.如何解决?可以用锁. 2java中synchronized的用法 用法1 public class Test{ public

  • Java并发问题之乐观锁与悲观锁

    首先介绍一些乐观锁与悲观锁: 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁.传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁.再比如Java里面的同步原语synchronized关键字的实现也是悲观锁. 乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版

  • JAVA如何解决并发问题

    并发问题的根源在哪 首先,我们要知道并发要解决的是什么问题?并发要解决的是单进程情况下硬件资源无法充分利用的问题.而造成这一问题的主要原因是CPU-内存-磁盘三者之间速度差异实在太大.如果将CPU的速度比作火箭的速度,那么内存的速度就像火车,而最惨的磁盘,基本上就相当于人双腿走路. 这样造成的一个问题,就是CPU快速执行完它的任务的时候,很长时间都会在等待磁盘或是内存的读写. 计算机的发展有一部分就是如何重复利用资源,解决硬件资源之间效率的不平衡,而后就有了多进程,多线程的发展.并且演化出了各种

  • Java多线程高并发中解决ArrayList与HashSet和HashMap不安全的方案

    1.ArrayList的线程不安全解决方案 将main方法的第一行注释打开,多执行几次,会看到如下图这样的异常信息:

  • 深入理解Java多线程与并发编程

    一.多线程三大特性 多线程有三大特性:原子性.可见性.有序性. 原子性 (跟数据库的事务特性中的原子性类似,数据库的原子性体现是dml语句执行后需要进行提交): 理解:即一个操作或多个操作,要么全部执行并且执行的过程中不会被任何因素打断,要么都不执行. 一个很经典的例子就是银行账户转账问题: 比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元.这2个操作必须要具备原子性才能保证不出现一些意外的问题. 我们操作数据也是如此,比如i = i+1:其

  • 浅谈mybatis 乐观锁实现,解决并发问题

    情景展示: 银行两操作员同时操作同一账户就是典型的例子. 比如A.B操作员同时读取一余额为1000元的账户,A操作员为该账户增加100元,B操作员同时为该账户扣除50元,A先提交,B后提交.最后实际账户余额为1000-50=950元,但本该为1000+100-50=1050.这就是典型的并发问题. 乐观锁机制在一定程度上解决了这个问题.乐观锁,大多是基于数据版本(Version)记录机制实现.何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 "

  • Java 常见的并发问题处理方法总结

    好像挺久没有写博客了,趁着这段时间比较闲,特来总结一下在业务系统开发过程中遇到的并发问题及解决办法,希望能帮到大家

  • Java基础之并发相关知识总结

    一.Java并发是什么? 用学术定义来说就是 并发:同一时间段,多个任务都在执行 (单位时间内不一定同时执行): 简单来说就是,同一个时间段,让计算机同时做多个事情. 说到并发,不得不提就是并行: 并行:单位时间内,多个任务同时执行. 两者大眼一看很像,仔细一想却并不相同,因为并行强调某个时间点多个任务同时执行,而并发强调的是一个时间段内多个任务都在执行. 二.怎么做? 大部分并发问题,最终都可以抽象成三类问题分工.同步和互斥.而且针对不同的问题有着不同的方式来解决,具体如下图所示: 三.分工

  • 详解Java多线程与并发

    一.进程与线程 进程:是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位. 线程:是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的 资源. 虽然系统是把资源分给进程,但是CPU很特殊,是被分配到线程的,所以线程是CPU分配的基本单位. 二者关系: 一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域. 程序计数器:是一块内存区域,用来记录线程当前要执行的指令地址 . 栈:用于存储该线程的局部变量,这些局部变量是

  • Java 处理高并发负载类优化方法案例详解

    java处理高并发高负载类网站中数据库的设计方法(java教程,java处理大量数据,java高负载数据) 一:高并发高负载类网站关注点之数据库 没错,首先是数据库,这是大多数应用所面临的首个SPOF.尤其是Web2.0的应用,数据库的响应是首先要解决的. 一般来说MySQL是最常用的,可能最初是一个mysql主机,当数据增加到100万以上,那么,MySQL的效能急剧下降.常用的优化措施是M-S(主-从)方式进行同步复制,将查询和操作和分别在不同的服务器上进行操作.我推荐的是M-M-Slaves

  • 关于SpringBoot 使用 Redis 分布式锁解决并发问题

    目录 问题背景 解决方案 主要实现原理: 可靠性: SpringBoot 集成使用 Redis 分布式锁 使用示例 参考文档 问题背景 现在的应用程序架构中,很多服务都是多副本运行,从而保证服务的稳定性.一个服务实例挂了,其他服务依旧可以接收请求.但是服务的多副本运行随之也会引来一些分布式问题,比如某个接口的处理逻辑是这样的:接收到请求后,先查询 DB 看是否有相关的数据,如果没有则插入数据,如果有则更新数据.在这种场景下如果相同的 N 个请求并发发到后端服务实例,就会出现重复插入数据的情况:

  • Java多线程解决龟兔赛跑问题详解

    目录 多线程4(龟兔赛跑-休眠线程) 1.题目 2.解题思路 3.代码详解 多线程4(龟兔赛跑-休眠线程) 1.题目 在龟兔赛跑中,领先的兔子因为通宵写博客,中途太累睡着了,跑输了乌龟.这个故事说明了兔子是爱学习的同学.咳咳,通宵是不可取的,大家别学. 实现:使用线程休眠模拟龟兔赛跑比赛 2.解题思路 创建一个类:RaceFrame,继承了JFrame.用来界面显示两个文本区域,用来输出乌龟和兔子的比赛记录,比赛开始按钮用来开始比赛. 编写内部类:Rabbit,该类实现了Runnable接口,在

随机推荐