Java并发编程(8)——常见的线程安全问题

线程安全问题:

多个线程同时执行也能工作的代码就是线程安全的代码
如果一段代码可以保证多个线程访问的时候正确操作共享数据,那么它是线程安全的。
具体说明:

java并发线程实战(1) 线程安全和机制原理

专栏总结java5:并发编程

总结常见的线程并发问题:
 

一、访问共享变量或资源


第一种场景是访问共享变量或共享资源的时候,典型的场景有访问共享对象的属性,访问 static 静态变量,访问共享的缓存,等等。因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题。比如我们讲过的多线程同时 i++ 的例子

1、竞态条件:多线程操作实例变量(对象属性)执行i++运行

实例变量:单例模式(只有一个对象实例存在)线程非安全,非单例线程安全。

实例变量为对象实例私有,在虚拟机的堆中分配,若在系统中只存在一个此对象的实例,在多线程环境下,“犹如”静态变量那样,被某个线程修改后,其他线程对修改均可见,故线程非安全;如果每个线程执行都是在不同的对象中,那对象与对象之间的实例变量的修改将互不影响,故线程安全。

++ 等情况导致的运行结果错误,通常是因为并发读写导致的


import java.util.concurrent.CountDownLatch;
public class TestAdd {
    private int count = 0;
 
    public static void main(String[] args) {
        CountDownLatch countDownLatch = new CountDownLatch(4);
        TestAdd add = new TestAdd();
        add.doAdd(countDownLatch);
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(add.getCount());
 
    }
    public void doAdd(CountDownLatch countDownLatch) {
        for (int i = 0; i < 4; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        count++;
                    }
                    countDownLatch.countDown();
                }
            }).start();
        }
    }
 
    public int getCount() {
        return count;
    }
}
————————————————

把变量自增4000次的例子,只不过用了4个线程,每个线程自增1000次,用CountDownLatch等4个线程执行完,打印出最终结果。实际上,我们希望程序的结果是4000,但是打印出来的结果并非总是4000。

count++看上去是一个操作,但实际上它包含三步(读取-修改-写入):

  • 读取count的值
  • 将值加1
  • 最后把计算结果赋值给count

由于不恰当的执行时序导致不正确结果的情况,是一种很常见的并发安全问题,被称为竞态条件

2、多线程操作对象集合对象

我们使用没有声明自己是线程安全的类时,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题。例如:

ArrayList 是线程不安全的, Vector 是线程安全的, ArrayList本身并不是线程安全的,如果此时多个线程同时对 ArrayList 进行并发读/写,那么就有可能会产生线程安全问题,造成数据出错
LinkedList 是线程不安全的,底层是由链表实现的

map接口的HashMap 不是线程安全的,HashTable 是线程安全。

具体可以看:
专栏总结java2:集合

Java(1)-Java中的Map List Set等集合类

看看ArrayList的例子:

ArrayList是线程不安全的,表现在多线程操作同一个ArrayList对象时的不安全。

public class UnsafeList {
    public static void main(String[] args) throws InterruptedException {
        List<String> list = new ArrayList<String>();
        for (int i = 0; i < 10000; i++) {
            new Thread( () -> {
                list.add(Thread.currentThread().getName());
            }).start();
        }
        //sleep保证上述for循环跑完再输出
        Thread.sleep(3000);
        //输出列表大小
        System.out.println(list.size());
    }
}
 

运行结果一般不会是10000的。问题是多线程操作了同一个共享变量,虽然开启了10000个线程往ArrayList里加数据,但有可能出现:

  1. 有时两个线程在同一个位置index进行add操作比如ArrayList[566] ,这样ArrayList的大小自然就不足10000了。

下面的例子类似:

public class ThreadProblem {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        ArrayList<Integer> list = new ArrayList<>();
        OperatorList m1 = new OperatorList(list);
        OperatorList m2 = new OperatorList(list);
        new Thread(m1).start();
        new Thread(m2).start();
    }
}


class OperatorList implements Runnable
{
    private ArrayList<Integer> list;

    public OperatorList(ArrayList<Integer> list) {
        this.list = list;
    }


    @Override
    public void run() {
        for (int i=0;i<20;i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(i);
            System.out.println(Thread.currentThread().getName()+"在第"+list.size()+"的位置增加了一项,现在容量为"+list.size());
        }
    }
}

3、静态变量的线程安全问题:

静态变量即类变量,位于方法区,为所有对象共享,共享一份内存,一旦静态变量被修改,其他对象均对修改可见,故线程非安全

public class StaticExample {
   volatile static int i;
   public static void main(String[] args) throws InterruptedException {
       Runnable r = new Runnable() {
           @Override
           public void run() {
               for (int j = 0; j < 10000; j++) {
                   i++;
               }
           }
       };

       Thread thread1 = new Thread(r);
       thread1.start();
       
       Thread thread2 = new Thread(r);
       thread2.start();

       thread1.join();
       thread2.join();
       
       System.out.println(i);

    }
}

静态变量 i,然后启动两个线程,分别对变量 i 进行 10000 次 i++ 操作。理论上得到的结果应该是 20000,但实际结果却远小于理论结果,比如可能是12996,也可能是13323,每次的结果都还不一样。

 静态方法操作静态变量如果修改静态变量操作和获取静态变量操作非原子操作时,就会引发线程不安全现象,我们可以通过synchronized同步块锁住该类的class锁监视器,是可以解决这种问题的。

二、依赖时序的操作


依赖时序的操作导致线程不安全的写法

先检查再执行:
  if(condition(a)) {
     handle(a);
  }

在实际开发中若是要这样写必定要确认这个a是不是多线程共享的,若是是共享的必定要在上面加个锁或者保证这两个操做是原子性的才能够。

如果我们操作的正确性是依赖时序的,而在多线程的情况下又不能保障执行的顺序和我们预想的一致,这个时候就会发生线程安全问题,如下面的代码所示:

if (map.containsKey(key)) {
    map.remove(obj)
}

代码中首先检查 map 中有没有 key 对应的元素,如果有则继续执行 remove 操作。

此时,这个组合操作就是危险的,因为它是先检查后操作,而执行过程中可能会被打断。

如果此时有两个线程同时进入 if() 语句,然后它们都检查到存在 key 对应的元素,于是都希望执行下面的 remove 操作,随后一个线程率先把 obj 给删除了,而另外一个线程它刚已经检查过存在 key 对应的元素,if 条件成立,所以它也会继续执行删除 obj 的操作,但实际上,集合中的 obj 已经被前面的线程删除了,这种情况下就可能导致线程安全问题。

类似的情况还有很多,比如我们先检查 x=1,如果 x=1 就修改 x 的值,代码如下所示:

if (x == 1) {
    x = 7 * x;
}

这样类似的场景都是同样的道理,“检查与执行”并非原子性操作,在中间可能被打断,而检查之后的结果也可能在执行时已经过期、无效,换句话说,获得正确结果取决于幸运的时序。这种情况下,我们就需要对它进行加锁等保护措施来保障操作的原子性。

三.活跃性问题


活跃性问题指的是,某个操作因为阻塞或循环,无法继续执行下去

最典型的有三种,分别为死锁、活锁和饥饿

什么是活跃性问题呢,活跃性问题就是程序始终得不到运行的最终结果,相比于前面两种线程安全问题带来的数据错误或报错,活跃性问题带来的后果可能更严重,比如发生死锁会导致程序完全卡死,无法向下运行。


1、死锁

死锁是指多个线程之间相互等待获取对方的锁,又不会释放自己占有的锁,而导致阻塞使得这些线程无法运行下去就是死锁,它往往是不正确的使用加锁机制以及线程间执行顺序的不可预料性引起的。如代码所示。

public class MayDeadLock {

    private Object o1 = new Object();
    private Object o2 = new Object();

    public void thread1() throws InterruptedException {
        synchronized (o1) {
            Thread.sleep(500);
            synchronized (o2) {
                System.out.println("线程1成功拿到两把锁");
            }
        }
    }

    public void thread2() throws InterruptedException {
        synchronized (o2) {
            Thread.sleep(500);
            synchronized (o1) {
                System.out.println("线程2成功拿到两把锁");
            }
        }
    }


    public static void main(String[] args) {
        MayDeadLock mayDeadLock = new MayDeadLock();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    mayDeadLock.thread1();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    mayDeadLock.thread2();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

    首先,代码中创建了两个 Object 作为 synchronized 锁的对象;
    线程 1 先获取 o1 锁,sleep(500) 之后,获取 o2 锁;
    线程 2 与线程 1 执行顺序相反,先获取 o2 锁,sleep(500) 之后,获取 o1 锁。

假设两个线程几乎同时进入休息,休息完后,线程 1 想获取 o2 锁,线程 2 想获取 o1 锁,这时便发生了死锁,两个线程不主动调和,也不主动退出,就这样死死地等待对方先释放资源,导致程序得不到任何结果也不能停止运行。

如何预防死锁

1.尽量保证加锁顺序是一样的,例如有A,B,C三把锁。

 保证加锁顺序是一样的

  • Thread 1的加锁顺序为A、B、C这样的。
  • Thread 2的加锁顺序为A、C,这样就不会死锁。

如果Thread2的加锁顺序为B、A或者C、A这样顺序就不一致了,就会出现死锁问题。

2.尽量用超时放弃机制

Lock接口提供了tryLock(long time, TimeUnit unit)方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。可以避免死锁问题


2、活锁

活锁与死锁非常相似,也是程序一直等不到结果,但对比于死锁,活锁是活的,什么意思呢?因为正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。

举一个例子,假设有一个消息队列,队列里放着各种各样需要被处理的消息,而某个消息由于自身被写错了导致不能被正确处理,执行时会报错,可是队列的重试机制会重新把它放在队列头进行优先重试处理,但这个消息本身无论被执行多少次,都无法被正确处理,每次报错后又会被放到队列头进行重试,周而复始,最终导致线程一直处于忙碌状态,但程序始终得不到结果,便发生了活锁问题。


3、饥饿

饥饿是指线程需要某些资源时始终得不到,尤其是CPU 资源,就会导致线程一直不能运行而产生的问题。

在 Java 中有线程优先级的概念,Java 中优先级分为 1 到 10,1 最低,10 最高。如果我们把某个线程的优先级设置为 1,这是最低的优先级,在这种情况下,这个线程就有可能始终分配不到 CPU 资源,而导致长时间无法运行。或者是某个线程始终持有某个文件的锁,而其他线程想要修改文件就必须先获取锁,这样想要修改文件的线程就会陷入饥饿,长时间不能运行。
 

四.final修饰成员变量的线程安全问题


1、final修饰变量的作用:

赋值后不能进行重新赋值。

final 修饰方法参数:方法内部不能对这个参数进行修改

final 修饰局部变量:它是不限定具体赋值时机的,只要求我们在使用之前必须对它进行赋值即可。一旦赋值就不能改变

final 修饰静态成员变量:只能是直接赋值或者静态的 static 初始代码块中初始化赋值,赋值后不能进行重新初始化赋值。

final 修饰普通成员变量:直接赋值或者在构造函数中赋值。赋值后不能进行重新赋值。

这里一定强调初始化赋值。

2、final 修饰成员变量却依然无法保证“不变性”

final修饰变量的作用赋值后不能进行重新赋值,即一旦变量初始化后,就不能再次进行初始化赋值。

结论:

 1、当对象成员变量是基本类型:修饰那就是当我们用 final 去修饰一个对象的基本类型( 8 种基本数据类型,例如 int 等)和String类型变量时候,保证这个变量不可变。

2、当对象成员变量是对象类型:那么 final 起到的作用只是保证这个变量的引用不可变,而对象本身的内容依然是可以变化的。

看看具体的例子:

FinalVar创建了一个 int 类型的变量、一个 Random 类型的变量,还有一个是数组,它们都是被 final 修饰的;然后尝试对它们进行修改,比如把 int 变量的值改成 1,或者把 random 变量置为 null,或者给数组重新指定一个内容,这些代码编辑器直接提示错误,更别提编译了:

被 final 修饰的变量意味着一旦被赋值就不能修改”,而这个规则对于基本类型的变量是没有问题,但是对于对象类型而言,final 其实只是保证这个变量的引用不可变,而对象本身依然是可以变化的。这一点同样适用于数组,因为在 Java 中数组也是对象。我们修改上面的例子数组内容是可以的:

public class FinalVar {
    private final int finalInt = 0;
    private Random random = new Random();
    private final int array[] = {1,2,3};
    private final String finalStr = "123";
    public static void main(String[] args) {
        FinalVar finalVar = new FinalVar();
//        finalVar.finalInt = 1;     //编译错误,不允许修改final的变量(基本类型)
//        finalVar.random = null;    //编译错误,不允许修改final的变量(对象)
//        finalVar.array = new int[5];//编译错误,不允许修改final的变量(数组)
//        finalVar.finalStr = "";//编译错误,不允许修改final的变量(数组)
        for (int i = 0; i < finalVar.array.length; i++) {
            finalVar.array[i] = finalVar.array[i]*10;
            System.out.println(finalVar.array[i]);
        }

    }
}

关键字 final 可以确保变量的引用保持不变,但是不变性意味着对象一旦创建完毕就不能改变其状态,它强调的是对象内容本身,而不是引用,所以 final 和不变性这两者是很不一样的。

3、final 和不可变的关系

对于一个类的对象而言,你必须要保证它创建之后所有内部状态(包括它的成员变量的内部属性等)永远不变,才是具有不变性的,这就要求所有成员变量的状态都不允许发生变化。

public class Person {
    final int id = 1;
    final int age = 18;
}

Person 类里面有 final int id 和 final int age 两个属性,都是基本类型的,且都加了 final,所以 Person 类的对象确实是具备不变性的。

如果一个类里面有一个 final 修饰的成员变量对象类型,它内部的成员变量还是可以变化的,因为 final 只能保证其引用不变,不能保证其内容不变。所以这个时候若一旦某个对象类型的内容发生了变化,就意味着这整个类都不具备不变性了

结论:不变性并不意味着,简单地使用 final 修饰所有类的属性,这个类的对象就具备不变性了。

若要保证对象不可变,即它赋值后没有机会在修改成本变量,就能保证对象不可变。

public class ImmutableDemo {
    private final Set<String> sets = new HashSet<>();
    public ImmutableDemo() {
        sets.add("1");
        sets.add("2");
        sets.add("3");
    }

    public boolean isConatin(String name) {
        return sets.contains(name);
    }
}

ImmutableDemo类的private属性sets是Set 对象且final修饰。

然后我们在构造函数中往这个 HashSet 里面加了三个值,类中没有其他成员函数修改sets

在这种情况下,尽管 sets 是 Set 类型对象,但是对于 ImmutableDemo 类的对象而言,就是具备不变性的。

4、string类是final的,为什么可以随便赋值? 

1)、final类是不能被其他类继承而已,是可以赋值的,不过只能赋值一次而已。

2)、当再次为一个String变量a赋值时,实际是创建新String对象并把该对象引用赋值给a变量。
        String a = "aaa";
        a = "bbb"
        a变量并不是重新赋值,只是指向另一个字符串而已。
        当第二次为一个String变量赋值时,实际上是重新创建的一个String对象,将这个新创建的对象引用赋值给之前的String变量,也就是说这个时候产生了两个对象,而不是同一个对象改变了值。

3)、常量的字符串连接会产生临时变量

    如:String str=”1”+”2”+”3“+”4”;就是有4个字符串常量:
         (1)首先”1”和”2”生成了”12”存在内存中,
         (2)然后”12”又和”3“ 生成 ”123“存在内存中
         (3)最后又和“4”生成了”1234”;并把这个字符串的地址赋给了str,
    就是因为String的“不可变”产生了很多临时变量,这也就是为什么建议用StringBuffer的
 

5、final变量是线程安全?

final成员变量是基本类型或者String类型是线程安全的。如果 final 修饰的是基本数据类型,那么它自然就具备了不可变这个性质,所以自动保证了线程安全。

如果final成员变量的类型是对象类型,该类型不是线程安全的,且可以进行修改,那么就不是线程安全。

虽然是final成员静态变量list,赋值后可以内其内容进行修改,由于Arraylist是线程不安全,打印结果没有到40。

public class FinalVar {
    public static void main(String[] args) {
        OperatorList m1 = new OperatorList();
        OperatorList m2 = new OperatorList();
        new Thread(m1).start();
        new Thread(m2).start();
    }
}
class OperatorList implements Runnable
{
    private static final  List<Integer> list = new ArrayList<>();

    @Override
    public void run() {
        for (int i=0;i<20;i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(i);
            System.out.println(Thread.currentThread().getName()+"在第"+list.size()+"的位置增加了一项,现在容量为"+list.size());
        }
    }
}

五.常见线程不安全类


1、集合:

list接口:ArrayList 是线程不安全的,  如果此时多个线程同时对 ArrayList 进行并发读/写,那么就有可能会产生线程安全问题,造成数据出错。

如果构造函数参数使用list作为外部参数传入: 如果没做线程同步, 做了remove/add操作, 这个类中对list的遍历, 就存在并发访问异常。

LinkedList 也是线程不安全。

map接口:HashMap 不是线程安全的,HastTable和ConcruuentHashMap是线程安全。

set接口:HashSet 基于 HashMap 来实现的,是一个不允许有重复元素的集合,所以也是线程不安全。LinkedHashSet有序、线程不安全类。

2、字符串

StringBuilder是线程不安全。StringBuffer是线程安全。

3、SimpleDateFormat线程不安全

类SimpleDateFormat主要负责日期的转换以及格式化,不要将SimpleDateFormat作为全局变量使用,SimpleDateFormat实际上是一个线程不安全的类,其根本原因是SimpleDateFormat的内部实现对一些共享变量的操作没有进行同步。

在多线程境下多个线程同时操作一个SimpleDateFormat对象会造成数据转换不准确,是非线程安全的类。

解决方案:

解决方案1:不要定义为static变量,使用局部变量

解决方案2:使用SimpleDateFormat代码块加锁:synchronized锁和Lock锁

解决方案3:使用ThreadLocal保证每一个线程有SimpleDateFormat对象副本。这样就能保证线程的安全。

解决方案4:推荐使用Java8的LocalDateTime和DateTimeFormatter

LocalDateTime和DateTimeFormatter是Java 8引入的新特性,它们不仅是线程安全的,而且使用更方便

解决方案5:使用FastDateFormat 替换SimpleDateFormat。FastDateFormat 是线程安全的,Apache Commons Lang包支持,不受限于java版本。

六.锁的正确释放和正确使用线程池


1、锁的正确释放

假设有这样一段伪代码:

Lock lock = new ReentrantLock(); 
...   
try{ 
  lock.tryLock(timeout, TimeUnit.MILLISECONDS) 
  //业务逻辑 
} 
catch (Exception e){ 
  //错误日志 
  //抛出异常或直接返回 
} 
finally { 
  //业务逻辑 
  lock.unlock(); 
} 
... 

这段代码中在finally代码块释放锁之前,执行了一段业务逻辑

假如不巧这段逻辑中依赖服务不可用导致占用锁的线程不能成功释放锁,会造成其他线程因无法获取锁而阻塞,最终线程池被打满的问题

所以在释放锁之前;finally子句中应该只有对当前线程占有的资源(如锁、IO流等)进行释放的一些处理

还有就是获取锁时设置合理的超时时间

为了避免线程因获取不到锁而一直阻塞,可以设置一个超时时间,当获取锁超时后,线程可以抛出异常或返回一个错误的状态码。其中超时时间的设置也要合理,不应过长,并且应该大于锁住的业务逻辑的执行时间。

2、正确使用线程池

案例1:不要将线程池作为局部变量使用

public void request(List<Id> ids) { 
  for (int i = 0; i < ids.size(); i++) { 
     ExecutorService threadPool = Executors.newSingleThreadExecutor(); 
  } 
} 

在for循环中创建线程池,那么每次执行该方法时,入参的list长度有多大就会创建多少个线程池,并且方法执行完后也没有及时调用shutdown()方法将线程池销毁

这样的话,随着不断有请求进来,线程池占用的内存会越来越多,就会导致频繁fullGC甚至OOM。每次方法调用都创建线程池是很不合理的,因为这和自己频繁创建、销毁线程没有区别,不仅没有利用线程池的优势,反而还会耗费线程池所需的更多资源

所以尽量将线程池作为全局变量使用

案例2:谨慎使用默认的线程池静态方法

Executors.newFixedThreadPool(int);     //创建固定容量大小的线程池 
Executors.newSingleThreadExecutor();   //创建容量为1的线程池 
Executors.newCachedThreadPool();       //创建一个线程池,线程池容量大小为Integer.MAX_VALUE 

上述三个默认线程池的风险点:

newFixedThreadPool创建的线程池corePoolSize和maximumPoolSize值是相等的,使用的阻塞队列是LinkedBlockingQueue。

newSingleThreadExecutor将corePoolSize和maximumPoolSize都设置为1,也使用的LinkedBlockingQueue

LinkedBlockingQueue默认容量为Integer.MAX_VALUE=2147483647,对于真正的机器来说,可以被认为是无界队列

  • newFixedThreadPool和newSingleThreadExecutor在运行的线程数超过corePoolSize时,后来的请求会都被放到阻塞队列中等待,因为阻塞队列设置的过大,后来请求不能快速失败而长时间阻塞,就可能造成请求端的线程池被打满,拖垮整个服务。

newCachedThreadPool将corePoolSize设置为0,将maximumPoolSize设置为Integer.MAX_VALUE,阻塞队列使用的SynchronousQueue,SynchronousQueue不会保存等待执行的任务

  • 所以newCachedThreadPool是来了任务就创建线程运行,而maximumPoolSize相当于无限的设置,使得创建的线程数可能会将机器内存占满。

所以需要根据自身业务和硬件配置创建自定义线程池

  • 3
    点赞
  • 13
    收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:游动-白 设计师:我叫白小胖 返回首页
评论

打赏作者

hguisu

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值