一、常见问题
从小的方面讲, 并发编程最常见的问题就是可见性、原子性和有序性问题。
从大的方面讲, 并发编程最常见的问题就是安全性问题、活跃性问题和性能问题。
下面主要从微观上分析问题。
二、可见性问题
可见性:一个线程对共享变量的修改,另外一个线程能够立马看到,这个称之为可见性。知道了可见性那么你就知道可见性问题了.
可见性问题:一个线程对共享变量的修改,但另一个线程感知不到其修改值的操作,读取的还是原来的值,这样会引起数据紊乱。
场景案例分析:以我们现实生活中为例,比如电影院卖票系统,假设一个电影院的座位有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并发编程的艺术》