Java并发编程-常见问题

1、常见问题

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

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

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

2、可见性问题

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

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

  场景案例分析:以咱们现实生活中为例,好比电影院卖票系统,假设一个电影院的座位有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关键字的用法就能够解决可见性的问题(由于上面案例还存在原子性问题,解决可见性问题后还不能使其结果变正确)性能

3、有序性问题

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

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

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

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关键字

4、原子性问题

  原子性:一个或多个操做在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);
    }
}
    

5、从宏观上分析问题

一、安全性问题

  类是否线程安全?是否按照指望的执行获得正确的结果? 若是知足条件则确定是安全的。可是会存在什么状况致使它不是安全的呢?

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

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

二、性能问题

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

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

  因此是全部状况都须要加锁吗?显然不是,须要具体问题具体分析而后采起具体解决方案。另外使用锁时要当心,否则就会带性能问题。

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

  • 尽可能使用无锁的算法或数据结果替代。
  • 若是使用锁,须要减小持有时间,不然会使其余线程一直等待。注意死锁的状况哦

三、活跃性问题

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

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

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

6、小结  

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

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

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

相关文章
相关标签/搜索