一、常见问题

  从小的方面讲, 并发编程最常见的问题就是可见性、原子性和有序性问题。

  从大的方面讲, 并发编程最常见的问题就是安全性问题、活跃性问题和性能问题。

  下面主要从微观上分析问题。

二、可见性问题

  可见性:一个线程对共享变量的修改,另外一个线程能够立马看到,这个称之为可见性。知道了可见性那么你就知道可见性问题了.

  可见性问题:一个线程对共享变量的修改,但另一个线程感知不到其修改值的操作,读取的还是原来的值,这样会引起数据紊乱。

  场景案例分析:以我们现实生活中为例,比如电影院卖票系统,假设一个电影院的座位有10000张,此时有两个影迷(同时)过来分别各买了5000张电影票,那么它还剩多少余票呢?下面我们看下代码实现:

public class VisibilityProblemTest {

    /**
* 电影票总数
*/
private int movieTicketAmount = 10000; /**
* 售票
*/
public void saleTicket(int n) {
/**
* 为了让问题能够明显一点,使用减1的操作,重复n次
*/
int i = 0;
while (i++ < n) {
movieTicketAmount -= 1;
}
} /**
* 返回剩余电影票
* @return int
*/
public int getMovieTicketAmount() {
return movieTicketAmount;
} public static void main(String[] args) throws InterruptedException { final VisibilityProblemTest ticket = new VisibilityProblemTest(); // 假设现在有两个用户分别购买5000张电影票
Thread user1 = new Thread(() -> ticket.saleTicket(5000));
Thread user2 = new Thread(() -> ticket.saleTicket(5000));
user1.start();
user2.start(); // 等待用户购买完成
user1.join();
user2.join(); // 售了10000张电影票后查验余数,理应还剩0张
System.out.println(ticket.getMovieTicketAmount());
Assert.assertEquals(ticket.getMovieTicketAmount(), 0);
}
}

  大家应该都猜到了,最终的余票不一定为0,有可能会大于0。因为其存在数据可见性问题(其实还存在原子性问题,后续说)

  问题原因:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了该线程以读/写共享变量的副本(本地内存是JMM的抽象概念,并不真实存在)。

  解决方法:从上面已经知道导致可见性的问题是因为缓存原因,那有什么方法可以禁用缓存呢。首先你得了解Java内存模型及其规范,然后了解volatile关键字的用法就可以解决可见性的问题(因为上面案例还存在原子性问题,解决可见性问题后还不能使其结果变正确)

三、有序性问题

  有序性:程序按照代码的先后顺序执行,称之为有序性。

  有序性问题:没有按照代码的先后顺序执行,导致很诡异的事情。

  场景案例分析:先看下面的简单案例:

a = 1;
b = 2;

  上面代码有可能执行的顺序为b = 2; a = 1;  这虽然不影响结果,但足以说明编译器有时调整语句的顺序。

  经典案例:利用双重检测机制创建单例对象。如下代码,在getInstance()方法中,先判断singleton实例是否为空,如果为空则锁定Singletonl类,再次判断singleton实例是否为空,为空则创建对象,最终返回实例。

public class Singleton {
private static Singleton singleton; private Singleton() {} /**
* 获取实例对象
* @return
*/
public static Singleton getInstance() {
// 第一重检测
if (null == singleton) {
       // 加锁 
synchronized(Singleton.class) {
// 第二重检测
if (null == singleton) {
            // 问题根源
singleton = new Singleton();
}
}
}
return singleton;
}
}

  看上去没啥问题, 那么在并发的场景中呢? 假想下:假设有两个线程同时过来获取对象,一开始都经历第一重检测,检测到为空则开始对Singleton类加锁,而JVM会保证只有一个线程获取到锁, 我们假设A线程获取到锁,则另一个线程(B线程)就会等待。A线程执行完后会创建singleton实例,释放锁后B线程成功获取锁,但是在第二重检测上会检测到singleton已经创建则直接返回了。 这样假设看起来不会存在问题, 但这样会出问题的。问题出在new 操作上,它其实可以拆解成三步。

  • 1.先给对象分配内存空间
  • 2.在内存上初始化Singleton对象
  • 3.将实例指向刚分配的内存地址

  如果按照上面顺序执行没有任何问题, 但是编译器会优化(重排序)指令,可能会得到这样的执行的顺序:1 -> 3 -> 2;  那么是这样的执行顺序会有导致什么样的结果呢?

  假设A线程先拿到锁然后执行到 1 -> 3 这步后(实例已经分配地址,但还没有被初始化)发生线程切换,此时进来B线程进来在第一重检测判断时,判断实例不为空则执行返回了。而此时singleton实例对象是没有分配内存,如果B线程拿次对象进行后续操作的话就会抛出空指针异常。

  问题原因:因为编译器/处理器会重排序执行指令(注意:不是所有指令都会重排),从而引发莫名奇妙的事情。

  解决方法:可以采取某些手段禁止重排序即可。针对上面案例,可以采用volatile关键字修饰singleton实例(插入内存屏障)。不懂的请多看下Java内存模型及其规范 和 volatile关键字

四、原子性问题

  原子性:一个或多个操作在CPU执行过程中不被中断的过程称为原子性。(与数据库中的原子性还是有区别的)。

  原子性问题:多个操作在执行过程中被中断(被其他线程抢走资源),就会引发各种问题。比如第一个例子中就存在原子性问题,从而导致共享数据不准确。

  场景案例分析:在第一个案例中,使用volatile关键字修饰movieTicketAmount,解决下可见性问题,如下代码:

public class AtomicProblemTest {

    /**
* 电影票总数
*/
private volatile int movieTicketAmount = 10000; /**
* 售票
*/
public void saleTicket(int n) {
int i = 0;
while (i++ < n) {
movieTicketAmount -= 1;
}
} /**
* 返回剩余电影票
* @return int
*/
public int getMovieTicketAmount() {
return movieTicketAmount;
} public static void main(String[] args) throws InterruptedException { final AtomicProblemTest ticket = new AtomicProblemTest(); // 假设现在有两个用户分别购买5000张电影票
Thread user1 = new Thread(() -> ticket.saleTicket(5000));
Thread user2 = new Thread(() -> ticket.saleTicket(5000)); user1.start();
user2.start(); // 等待用户购买完成
user1.join();
user2.join(); // 售了1000张电影票后查验余数,理应还剩0张
System.out.println(ticket.getMovieTicketAmount());
Assert.assertEquals(ticket.getMovieTicketAmount(), 0);
}
}

  那么上面案例在哪存在问题呢?其实就在movieTicketAmount -= 1 这行代码上,它其实是一个复合操作需拆解成三个步骤进行加载:

  • 先会读取变量的值加载至寄存器;
  • 进行-1操作
  • 然后将值加载至内存(volatile作用)

  由于有volatile关键字修饰,就不需要考虑它会不会重排或者说对其他线程可不可见了,这里最主要的原因是不能保证原子性。假想下:当变量值为10000时,此时进来A线程且执行完第一步或者第二步的时候,需要让出资源给B线程执行,当B线程执行完这个复合操作时movieTicketAmount=9999刷新内存值,然后A线程继续执行(它之前读取movieTicketAmount=10000)执行完复合操作的结果也是9999则会覆盖之前内存的值。这样则会与预期的结果9998不一样就会造成数据紊乱了。

  解决方法:将多个操作变成原子性,比方说在saleTicket方法上加锁。在此案例中还有另外的解决方法:将movieTicketAmount用原子性类修饰-> AmoticInteger。如下:

public class AtomicProblemTest {

    /**
* 电影票总数,使用volatile修饰,以及使用原子性类
*/
private volatile AtomicInteger movieTicketAmount = new AtomicInteger(10000); /**
* 售票
*/
public void saleTicket(int n) {
int i = 0;
while (i++ < n) {
// 注意用法
movieTicketAmount.getAndDecrement();
}
} /**
* 返回剩余电影票
* @return int
*/
public int getMovieTicketAmount() {
return movieTicketAmount.get();
} public static void main(String[] args) throws InterruptedException { final AtomicProblemTest ticket = new AtomicProblemTest(); // 假设现在有两个用户分别购买5000张电影票
Thread user1 = new Thread(() -> ticket.saleTicket(5000));
Thread user2 = new Thread(() -> ticket.saleTicket(5000)); user1.start();
user2.start(); // 等待用户购买完成
user1.join();
user2.join(); // 售了1000张电影票后查验余数,理应还剩0张
System.out.println(ticket.getMovieTicketAmount());
Assert.assertEquals(ticket.getMovieTicketAmount(), 0);
}
}

五、从宏观上分析问题

1、安全性问题

  类是否线程安全?是否按照期望的执行得到正确的结果? 如果满足条件则肯定是安全的。但是会存在什么情况导致它不是安全的呢?

  • 数据竞争。当多个线程访问同一个数据并且至少有一个线程对这个数据进行写操作的情况,就会存在数据竞争。针对这种情况如果不加以防护,那么就会导致并发的bug(通过上面微观性方面分析应该知道会导致什么样的结果)
  • 竞态条件。 程序执行结果依赖程序执行顺序,所以这种情况如果允许所有执行重排就会出现问题。另外特别要注意这种操作:“先检查后执行”, 这种最容易出现竞态条件。

  那么怎么解决呢?这两种都可以采取简单粗暴的方法:加锁  

2、性能问题

  在某个场景使用某个类或者使用数据结构的时候需要考虑其性能问题,而衡量性能最重要的指标:吞吐量、延迟、并发量。

  • 吞吐量:指单位时间能处理的请求数量。也叫QPS, 吞吐量越大性能越好。
  • 延迟:指请求从发出到响应的时间。延迟越小性能越好。
  • 并发量:指同时能处理的并发请求。

  所以是所有情况都需要加锁吗?显然不是,需要具体问题具体分析然后采取具体解决方案。另外使用锁时要小心,不然就会带性能问题。

  那么怎么避免性能问题呢?

  • 尽量使用无锁的算法或数据结果替代。
  • 如果使用锁,需要减少持有时间,否则会使其他线程一直等待。注意死锁的情况哦

3、活跃性问题

  活跃性问题:指程序是否能否执行下去。那么从上述分析就可以看出,死锁问题就会导致活跃性问题。

  另外除了死锁,还存在“活锁”和“饥饿”问题。

  • 活锁:指线程虽然没有受到阻塞,但是由于某些条件没有满足会导致一直重复尝试—失败—尝试—失败的过程。可以采取尝试指定时间自动取消尝试。
  • 饥饿:指线程因无法访问所需要资源而无法执行下去。解决此问题:保证资源充足、公平分配资源、避免长时间吃锁

六、小结  

  并发编程真是个复杂的领域,所以遇到这块时需要谨慎,多处分析问题,同时多注意上面两个大方面分析的方面。遇上问题先把问题分析清楚,然后具体问题具体分析。

  上处如有错误之处,敬请指处。

  参考文献:《Java并发编程的艺术》

 

  

Java并发编程-核心问题(1)的更多相关文章

  1. Java并发编程核心知识体系精讲

    第1章 开宗明义[不看错过一个亿]本章一连串设问:为什么学并发编程?学并发编程痛点?谁适合学习本课?本课程包含内容和亮点?首先4大个理由告诉你为什么要学,其实源于JD岗位要求就不得不服了.其次5个痛点 ...

  2. Java并发编程核心概念一览

    作者博客地址 https://muggle.javaboy.org. 并行相关概念 同步和异步 同步和异步通常来形容一次方法的调用.同步方法一旦开始,调用者必须等到方法结束才能执行后续动作:异步方法则 ...

  3. Java并发编程核心方法与框架-CountDownLatch的使用

    Java多线程编程中经常会碰到这样一种场景:某个线程需要等待一个或多个线程操作结束(或达到某种状态)才开始执行.比如裁判员需要等待运动员准备好后才发送开始指令,运动员要等裁判员发送开始指令后才开始比赛 ...

  4. Java并发编程核心方法与框架-TheadPoolExecutor的使用

    类ThreadPoolExecutor最常使用的构造方法是 ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAli ...

  5. Java并发编程核心方法与框架-Semaphore的使用

    Semaphore中文含义是信号.信号系统,这个类的主要作用就是限制线程并发数量.如果不限制线程并发数量,CPU资源很快就会被耗尽,每个线程执行的任务会相当缓慢,因为CPU要把时间片分配给不同的线程对 ...

  6. Java并发编程核心方法与框架-Fork-Join分治编程(一)

    在JDK1.7版本中提供了Fork-Join并行执行任务框架,它的主要作用是把大任务分割成若干个小任务,再对每个小任务得到的结果进行汇总,这种开发方法也叫做分治编程,可以极大地利用CPU资源,提高任务 ...

  7. Java并发编程核心方法与框架-CompletionService的使用

    接口CompletionService的功能是以异步的方式一边生产新的任务,一边处理已完成任务的结果,这样可以将执行任务与处理任务分离.使用submit()执行任务,使用take取得已完成的任务,并按 ...

  8. Java并发编程核心方法与框架-ScheduledExecutorService的使用

    类SchedukedExecutorService的主要作用是可以将定时任务与线程池功能结合. 使用Callable延迟运行(有返回值) public class MyCallableA implem ...

  9. Java并发编程核心方法与框架-ExecutorService的使用

    在ThreadPoolExecutor中使用ExecutorService中的方法 方法invokeAny()和invokeAll()具有阻塞特性 方法invokeAny()取得第一个完成任务的结果值 ...

随机推荐

  1. java中如何通过Class获取类的属性、方法、注释

    public static String getPKName(Class clazz) { Field[] fields = clazz.getDeclaredFields(); for (Field ...

  2. AI_ 视频监控-人体移动捕捉监测

    总目录地址:AI 系列 总目录 需要最新源码,或技术提问,请加QQ群:538327407 我的各种github 开源项目和代码:https://github.com/linbin524 需求 为了实现 ...

  3. C# 对接Https接口

    最近公司项目需要对接Https接口,将对接的代码整理如下: public void Get() { HttpWebRequest request = null; request = WebReques ...

  4. 机器学习、深度学习、和AI算法可以在网络安全中做什么?

    本文由  网易云发布. 本文作者:Alexander Polyakov,ERPScan的首席技术官和联合创始人.EAS-SEC总裁,SAP网络安全传播者. 现在已经出现了相当多的文章涉及机器学习及其保 ...

  5. .net core api Post请求

    POST请求: 1.请求类型:Content-Type:application/json,参数:json字符串 后台接收参数: 1)([FromBody]UserInfo user)必须以对象形式接收 ...

  6. jQuery ajax的jsonp跨域请求

    一直在听“跨域跨域”,但是什么是跨域呢?今天做了一些了解.(利用jQuery的jsonp) jQuery使用JSONP跨域 JSONP跨域是利用script脚本允许引用不同域下的js实现的,将回调方法 ...

  7. BZOJ3775: 点和直线(计算几何+拉格朗日乘数法)

    题面 传送门 题解 劲啊-- 没有和\(Claris\)一样推,用了类似于\(Shinbokuow\)推已知点求最短直线的方法,结果\(WA\)了好几个小时,拿\(Claris\)代码拍了几个小时都没 ...

  8. Django准备知识-web应用、http协议、web框架、Django简介

    一.web应用 Web应用程序是一种可以通过web访问的应用程序(web应用本质是基于socket实现的应用程序),程序的最大好处是用户很容易访问应用程序,用户只需要有浏览器即可,不需要再安装其他软件 ...

  9. MySQL(视图、触发器、函数)

    day61 参考:http://www.cnblogs.com/wupeiqi/articles/5713323.html 视图 视图:给某个查询语句设置别名,日后方便使用               ...

  10. Android之AppWidget

    1.Widget设计步骤 需要修改三个XML,一个class: 1)第一个xml是布局XML文件(如:main.xml),是这个widget的.一般来说如果用这个部件显示时间,那就只在这个布局XML中 ...