聊聊并发编程中的10个坑

Posted 苏三说技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊聊并发编程中的10个坑相关的知识,希望对你有一定的参考价值。

前言

对于从事后端开发的同学来说,并发编程肯定再熟悉不过了。

说实话,在java中并发编程是一大难点,至少我是这么认为的。不光理解起来比较费劲,使用起来更容易踩坑。

不信,让继续往下面看。

今天重点跟大家一起聊聊并发编程的10个坑,希望对你有帮助。

1. SimpleDateFormat线程不安全

在java8之前,我们对时间的格式化处理,一般都是用的SimpleDateFormat类实现的。例如:

@Service
public class SimpleDateFormatService 

    public Date time(String time) throws ParseException 
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return dateFormat.parse(time);
    

如果你真的这样写,是没问题的。

就怕哪天抽风,你觉得dateFormat是一段固定的代码,应该要把它抽取成常量。

于是把代码改成下面的这样:

@Service
public class SimpleDateFormatService 

   private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public Date time(String time) throws ParseException 
        return dateFormat.parse(time);
    

dateFormat对象被定义成了静态常量,这样就能被所有对象共用。

如果只有一个线程调用time方法,也不会出现问题。

但Serivce类的方法,往往是被Controller类调用的,而Controller类的接口方法,则会被tomcat线程池调用。换句话说,可能会出现多个线程调用同一个Controller类的同一个方法,也就是会出现多个线程会同时调用time方法。

而time方法会调用SimpleDateFormat类的parse方法:

@Override
public Date parse(String text, ParsePosition pos) 
    ...
    Date parsedDate;
    try 
        parsedDate = calb.establish(calendar).getTime();
        ...
     catch (IllegalArgumentException e) 
        pos.errorIndex = start;
        pos.index = oldStart;
        return null;
    
   return parsedDate;
 

该方法会调用establish方法:

Calendar establish(Calendar cal) 
    ...
    //1.清空数据
    cal.clear();
    //2.设置时间
    cal.set(...);
    //3.返回
    return cal;

其中的步骤1、2、3是非原子操作。

但如果cal对象是局部变量还好,坏就坏在parse方法调用establish方法时,传入的calendar是SimpleDateFormat类的父类DateFormat的成员变量:

public abstract class DateFormat extends Forma 
    ....
    protected Calendar calendar;
    ...

这样就可能会出现多个线程,同时修改同一个对象即:dateFormat,它的同一个成员变量即:Calendar值的情况。

这样可能会出现,某个线程设置好了时间,又被其他的线程修改了,从而出现时间错误的情况。

那么,如何解决这个问题呢?

  1. SimpleDateFormat类的对象不要定义成静态的,可以改成方法的局部变量。
  2. 使用ThreadLocal保存SimpleDateFormat类的数据。
  3. 使用java8的DateTimeFormatter类。

2. 双重检查锁的漏洞

单例模式无论在实际工作,还是在面试中,都出现得比较多。

我们都知道,单例模式有:饿汉模式懒汉模式两种。

饿汉模式代码如下:

public class SimpleSingleton 
    //持有自己类的引用
    private static final SimpleSingleton INSTANCE = new SimpleSingleton();

    //私有的构造方法
    private SimpleSingleton() 
    
    //对外提供获取实例的静态方法
    public static SimpleSingleton getInstance() 
        return INSTANCE;
    

使用饿汉模式的好处是:没有线程安全的问题,但带来的坏处也很明显。

private static final SimpleSingleton INSTANCE = new SimpleSingleton();

一开始就实例化对象了,如果实例化过程非常耗时,并且最后这个对象没有被使用,不是白白造成资源浪费吗?

还真是啊。

这个时候你也许会想到,不用提前实例化对象,在真正使用的时候再实例化不就可以了?

这就是我接下来要介绍的:懒汉模式

具体代码如下:

public class SimpleSingleton2 

    private static SimpleSingleton2 INSTANCE;

    private SimpleSingleton2() 
    

    public static SimpleSingleton2 getInstance() 
        if (INSTANCE == null) 
            INSTANCE = new SimpleSingleton2();
        
        return INSTANCE;
    

示例中的INSTANCE对象一开始是空的,在调用getInstance方法才会真正实例化。

嗯,不错不错。但这段代码还是有问题。

假如有多个线程中都调用了getInstance方法,那么都走到 if (INSTANCE == null) 判断时,可能同时成立,因为INSTANCE初始化时默认值是null。这样会导致多个线程中同时创建INSTANCE对象,即INSTANCE对象被创建了多次,违背了只创建一个INSTANCE对象的初衷。

为了解决饿汉模式懒汉模式各自的问题,于是出现了:双重检查锁

具体代码如下:

public class SimpleSingleton4 

    private static SimpleSingleton4 INSTANCE;

    private SimpleSingleton4() 
    

    public static SimpleSingleton4 getInstance() 
        if (INSTANCE == null) 
            synchronized (SimpleSingleton4.class) 
                if (INSTANCE == null) 
                    INSTANCE = new SimpleSingleton4();
                
            
        
        return INSTANCE;
    

需要在synchronized前后两次判空。

但我要告诉你的是:这段代码有漏洞的。

有什么问题?

public static SimpleSingleton4 getInstance() 
    if (INSTANCE == null) //1
        synchronized (SimpleSingleton4.class) //2
            if (INSTANCE == null) //3
                INSTANCE = new SimpleSingleton4();//4
            
        
    
    return INSTANCE;//5

getInstance方法的这段代码,我是按1、2、3、4、5这种顺序写的,希望也按这个顺序执行。

但是java虚拟机实际上会做一些优化,对一些代码指令进行重排。重排之后的顺序可能就变成了:1、3、2、4、5,这样在多线程的情况下同样会创建多次实例。重排之后的代码可能如下:

public static SimpleSingleton4 getInstance() 
    if (INSTANCE == null) //1
       if (INSTANCE == null) //3
           synchronized (SimpleSingleton4.class) //2
                INSTANCE = new SimpleSingleton4();//4
            
        
    
    return INSTANCE;//5

原来如此,那有什么办法可以解决呢?

答:可以在定义INSTANCE是加上volatile关键字。具体代码如下:

public class SimpleSingleton7 

    private volatile static SimpleSingleton7 INSTANCE;

    private SimpleSingleton7() 
    

    public static SimpleSingleton7 getInstance() 
        if (INSTANCE == null) 
            synchronized (SimpleSingleton7.class) 
                if (INSTANCE == null) 
                    INSTANCE = new SimpleSingleton7();
                
            
        
        return INSTANCE;
    

volatile关键字可以保证多个线程的可见性,但是不能保证原子性。同时它也能禁止指令重排

双重检查锁的机制既保证了线程安全,又比直接上锁提高了执行效率,还节省了内存空间。

此外,如果你想了解更多单例模式的细节问题,可以看看我的另一篇文章《单例模式,真不简单

3. volatile的原子性

从前面我们已经知道volatile,是一个非常不错的关键字,它能保证变量在多个线程中的可见性,它也能禁止指令重排,但是不能保证原子性

使用volatile关键字禁止指令重排,前面已经说过了,这里就不聊了。

可见性主要体现在:一个线程对某个变量修改了,另一个线程每次都能获取到该变量的最新值。

先一起看看反例:

public class VolatileTest extends Thread 

    private  boolean stopFlag = false;

    public boolean isStopFlag() 
        return stopFlag;
    

    @Override
    public void run() 
        try 
            Thread.sleep(300);
         catch (InterruptedException e) 
            e.printStackTrace();

        
        stopFlag = true;
        System.out.println(Thread.currentThread().getName() + " stopFlag = " + stopFlag);
    

    public static void main(String[] args) 
        VolatileTest vt = new VolatileTest();
        vt.start();

        while (true) 
            if (vt.isStopFlag()) 
                System.out.println("stop");
                break;
            
        
    

上面这段代码中,VolatileTest是一个Thread类的子类,它的成员变量stopFlag默认是false,在它的run方法中修改成了true。

然后在main方法的主线程中,用vt.isStopFlag()方法判断,如果它的值是true时,则打印stop关键字。

但vt.isStopFlag()的结果始终是false。

那么,如何才能让stopFlag的值修改了,在主线程中通过vt.isStopFlag()方法,能够获取最新的值呢?

正例如下:

public class VolatileTest extends Thread 

    private volatile boolean stopFlag = false;

    public boolean isStopFlag() 
        return stopFlag;
    

    @Override
    public void run() 
        try 
            Thread.sleep(300);
         catch (InterruptedException e) 
            e.printStackTrace();

        
        stopFlag = true;
        System.out.println(Thread.currentThread().getName() + " stopFlag = " + stopFlag);
    

    public static void main(String[] args) 
        VolatileTest vt = new VolatileTest();
        vt.start();

        while (true) 
            if (vt.isStopFlag()) 
                System.out.println("stop");
                break;
            
        
    

volatile关键字修饰stopFlag即可。

下面重点说说volatile的原子性问题。

使用多线程给count加1,代码如下:

public class VolatileTest 

    public volatile int count = 0;

    public void add() 
        count++;
    

    public static void main(String[] args) 
        final VolatileTest test = new VolatileTest();
        for (int i = 0; i < 20; i++) 
            new Thread() 
                @Override
                public void run() 
                    for (int j = 0; j < 1000; j++) 
                        test.add();
                    
                

                ;
            .start();
        
        while (Thread.activeCount() > 2) 
            //保证前面的线程都执行完
            Thread.yield();
        

        System.out.println(test.count);
    

执行结果每次都不一样,但可以肯定的是count值每次都小于20000,比如:19999。

这个例子中count是成员变量,虽说被定义成了volatile的,但由于add方法中的count++是非原子操作。在多线程环境中,count++的数据可能会出现问题。

由此可见,volatile不能保证原子性

那么,如何解决这个问题呢?

答:使用synchronized关键字。

改造后的代码如下:

public class VolatileTest 

    public int count = 0;

    public synchronized void add() 
        count++;
    

    public static void main(String[] args) 
        final VolatileTest test = new VolatileTest();
        for (int i = 0; i < 20; i++) 
            new Thread() 
                @Override
                public void run() 
                    for (int j = 0; j < 1000; j++) 
                        test.add();
                    
                

                ;
            .start();
        
        while (Thread.activeCount() > 2) 
            //保证前面的线程都执行完
            Thread.yield();
        

        System.out.println(test.count);
    

4. 死锁

死锁可能是大家都不希望遇到的问题,因为一旦程序出现了死锁,如果没有外力的作用,程序将会一直处于资源竞争的假死状态中。

死锁代码如下:

public class DeadLockTest 

    public static String OBJECT_1 = "OBJECT_1";
    public static String OBJECT_2 = "OBJECT_2";

    public static void main(String[] args) 
        LockA lockA = new LockA();
        new Thread(lockA).start();

        LockB lockB = new LockB();
        new Thread(lockB).start();
    



class LockA implements Runnable 

    @Override
    public void run() 
        synchronized (DeadLockTest.OBJECT_1) 
            try 
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_2) 
                    System.out.println("LockA");
                
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
    


class LockB implements Runnable 

    @Override
    public void run() 
        synchronized (DeadLockTest.OBJECT_2) 
            try 
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_1) 
                    System.out.println("LockB");
                
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
    

一个线程在获取OBJECT_1锁时,没有释放锁,又去申请OBJECT_2锁。而刚好此时,另一个线程获取到了OBJECT_2锁,也没有释放锁,去申请OBJECT_1锁。由于OBJECT_1和OBJECT_2锁都没有释放,两个线程将一起请求下去,陷入死循环,即出现死锁的情况。

那么如果避免死锁问题呢?

4.1 缩小锁的范围

出现死锁的情况,有可能是像上面那样,锁范围太大了导致的。

那么解决办法就是缩小锁的范围

具体代码如下:

class LockA implements Runnable 

    @Override
    public void run() 
        synchronized (DeadLockTest.OBJECT_1) 
            try 
                Thread.sleep(500);
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
        synchronized (DeadLockTest.OBJECT_2) 
             System.out.println("LockA");
        
    


class LockB implements Runnable 

    @Override
    public void run() 
        synchronized (DeadLockTest.OBJECT_2) 
            try 
                Thread.sleep(500);
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
        synchronized (DeadLockTest.OBJECT_1) 
             System.out.println("LockB");
        
    

在获取OBJECT_1锁的代码块中,不包含获取OBJECT_2锁的代码。同时在获取OBJECT_2锁的代码块中,也不包含获取OBJECT_1锁的代码。

4.2 保证锁的顺序

出现死锁的情况说白了是,一个线程获取锁的顺序是:OBJECT_1和OBJECT_2。而另一个线程获取锁的顺序刚好相反为:OBJECT_2和OBJECT_1。

那么,如果我们能保证每次获取锁的顺序都相同,就不会出现死锁问题。

具体代码如下:

class LockA implements Runnable 

    @Override
    public void run() 
        synchronized (DeadLockTest.OBJECT_1) 
            try 
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_2) 
                    System.out.println("LockA");
                
             catch (InterruptedException e) 
                e.printStackTrace();
            
        
    


class LockB implements Runnable 

    @Override
    public void run() 
        synchronized (DeadLockTest.OBJECT_1) 
            try 
                Thread.sleep(500);

                synchronized (DeadLockTest.OBJECT_2) 
                    System.out.println("LockB");
   

以上是关于聊聊并发编程中的10个坑的主要内容,如果未能解决你的问题,请参考以下文章

聊聊并发编程的10个坑

聊聊并发编程的12种业务场景

聊聊并发编程的12种业务场景

使用线程池的10个坑

使用线程池的10个坑

使用线程池的10个坑