Java多线程与并发(4):安全发布对象和线程安全策略

       我的上一篇文章线程安全性详解在讲解线程安全性的同时,也剖析了Java的atomic所体现的原子性、CAS原理和对应的源码解读,另外讲述了synchronized、volatile关键字,本篇文章将要介绍的是安全发布对象和线程安全策略,大家初次看到这两个名词可能会有点懵,我刚开始接触这两个概念的时候也是不知道是干嘛的,和并发的情况有什么关联。本篇文章就是详细介绍这两块内容,网上也有很多相关的文章,但是基本相似并且从中不能得到更深刻的理解,在写这篇文章之前我多次看了网上其他文章的内容并且反复思考之后才写的,文章内容也会和其他文章大致一样,不过会包含我自己对这部分内容的理解,希望我的这篇文章能对大家有所帮助!

一、安全发布对象

       首先大家要明白的是这篇文章是在多线程与并发章节下的,所以该内容是与并发有关,也就会涉及到多线程并发情况下的线程安全问题。本篇文章我会分别介绍:什么是发布对象?什么是对象逸出?为什么要安全发布对象以及如何做到安全发布对象。

1、什么是发布对象

       “对象”应该知道是什么吧?不是指你的对象,是指Java程序员眼里的对象!在Java程序员的眼里:万事万物皆对象,我们写的Java程序也就是用一个个类(Class)来描述某类事物以及所能做的某些功能,那在java程序里面如果要使用这个类就会创建这个类的实例对象,举个简单例子比如我们在写程序的时候要用代码来描述人,那在我们就可以用类来简单描述人:

/**
 * 人  --> 属于一类物种
 */
public class Person {

    private String name; // 每个人都有自己的姓名, 所以这是人的姓名属性

    private int sex; // 每个人都有自己的年龄,所以这是人的年龄属性

    public Person(String name, int sex){
        this.name = name;
        this.sex = sex;
    }

    public void eat(){ // 每个人都能吃东西, 所以有吃的功能
        System.out.println("吃东西");
    }

    public String getName() {
        return name;
    }

    public int getSex() {
        return sex;
    }
}

上面就是用Java的类(Class)来描述人这个一类事物,上面的类是描述人这一类物种,属于泛指,如果要一个具体的人我们就会创建人这个类的实例对象,比如new Person("张三", 19)之后就会有这么一个具体的实例对象,这就是对象(对象的解释这一块说的有点多,大佬们就当我在啰里啰嗦),简而言之就是我们创建的类的实例!

       那发布对象是啥?从字面意思解读就是把将创建的类的实例对象发布出去。网上统一的解释:使一个对象能够被当前范围之外的代码所使用。其实我个人一直认为这一句解释有点怪怪的,当前范围之外是指什么范围?当前范围之外的代码又是哪些代码?我到现在还不知道怎么理解这句话,如果有更具体的解释欢迎在评论区留言。

       在遇到一些之前没接触过的内容时,除了看别人的解释之外,其实我更希望大家也能对所有接触到的新的知识能有自己的一份理解和总结,对于发布对象这么个名词我自己的理解就是:对象被创建之后(谁创建的?说到底还不是某个线程嘛!),发布出去被程序的其他地方使用这个对象,具体被谁使用呢?说到底也还不是被某个线程使用哦(注意!我提示一下这里的某个线程可能是创建这个对象的线程,也可能是其他的线程!),没错吧?

       结合我上面自己的理解到这里我感觉可以尝试解开我上面提到的两个疑惑了,我们再来解释一下这句话:使一个对象能够被当前范围之外的代码所使用。 当前范围:应该是指创建这个对象的线程;当前范围之外的代码:其他的线程,或许我的理解和解释会有出入,反正我是按照这么个理解去开展后续内容的讲解的,如有出入欢迎评论区指正!

2、为什么要安全发布对象

       为什么要安全发布对象?因为发布对象会存在不安全,所以要安全发布对象!这句仿佛是一句废话,我们重点要知道的是什么情况下会出现不安全的情况。我拿曾经犯过一个挺蠢的错误来描述这种不安全的场景吧,就是在做项目的时候有这么一个大致的业务流程:前端调后台服务接口后controller层接收然后再调service层,service层的方法体里面需要调其他服务的接口,看下面例子:

controller层的代码:

public class Controller {
    public static void main(String[] args) {

        /**
         * 该例子用main方法模拟controller层接收外部调用
         * 然后调用服务层方法serviceMethod
         *
         * 新建线程来模拟controller被外部调用
         * 这里就创建三个线程模拟controller被调用了三次
         */
        for(int i = 0; i < 3; i++){
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    Service service = new Service(); // 真正的项目并不是这样去调的哈,对应的实例会通过@Autowired注解注入的
                    service.serviceMethod();
                }
            });
            thread.start(); // 模拟调用
        }
    }
}

service层的代码:

public class Service {

    // 发布的String类型的对象,供所有线程使用
    private static String sericeUrl = "http://xxx.com/prefix"; // 另外一个服务的地址前缀

    /**
     * 服务层Service的某个方法,由controller层调进来
     */
    public void serviceMethod(){
        sericeUrl += "suffix_Url"; // 服务域名加上后缀地址

        // todo  根据拼装好的服务url 调用起接口地址
        System.out.println("post方式调用其他服务接口地址:" + sericeUrl);
    }
}

结果输出:

post方式调用其他服务接口地址:http://xxx.com/prefixsuffix_Url
post方式调用其他服务接口地址:http://xxx.com/prefixsuffix_Urlsuffix_Url
post方式调用其他服务接口地址:http://xxx.com/prefixsuffix_Urlsuffix_Urlsuffix_Url

       这是一个好蠢的错误,我们来分析一下流程,当真正的项目启动之后,Service类被触发加载再到初始化之后(关于类的生命周期相关可以看我的博客:Java类的生命周期和加载机制),会发布一个String类型的对象serviceUrl,对象的值为另一个项目服务地址的前缀,并且这个对象会在下一次项目重启之前都存在内存当中。具体的serviceMethod做的事情是拼装对应服务的具体的接口地址然后进行调用,但是按照我上面的 serviceUrl += "suffix_Url";的写法的话serviceUrl这个变量是会重新指向一个新的String对象的(注意这里是serviceUrl引用的对象改变了,而不是它的值,serviceUrl只是一个引用类型的变量,String类是用final修饰的,所以每次我们进行像上面那样拼装出来的字符串其实都是新创建的String对象),那问题就来了,每次controller层接收新的外部请求之后会启用一个线程来处理这次请求(我这里是新建线程模拟请求流程),第一次的请求是没有问题的,因为此时拼装的地址是一个正确的,但是第二和第三次以及后续的请求会不断的在原有的地址值后面追加后缀,serviceUrl每次指向一个新的对象,就如上面的输出结果一样,是不是犯的错误很蠢?没办法当时还是太年轻。。。

      其实这里就暴露了不安全发布对象的一个实际场景,怎么说呢?按道理serviceUrl所指向对象的值是供所有线程共同读的公共的服务地址前缀,但是却被使用它的线程进行了修改,而导致后续的线程拿到一个错误值。那怎么解决这个问题呢?当然是在方法里面每次都新建一个String对象来拼装地址啦,而不是像我上面那样写。这个例子很好看出问题并且用我说的这种解决方式解决,但是这并不能解决不安全隐患,比如你作为对象的发布方给其他地方提供了一个公共的对象,难道你要告诉所有使用的地方都不允许修改你这个对象的内容嘛?即使使用方都答应不会修改,但是也会出现不小心修改的情况,还是会存在安全隐患,安全隐患应该在你提供的时候就解决掉,就拿这个例子来说,应该给serviceUrl加上关键字final:

public class Service {

    // 发布的String类型的对象,供所有线程使用,加了final关键字
    private final static String sericeUrl = "http://xxx.com/prefix"; // 另外一个服务的地址前缀

    /**
     * 服务层Service的某个方法,由controller层调进来
     */
    public void serviceMethod(){
        sericeUrl += "suffix_Url"; // 此时这里就会出现编译报错,服务域名加上后缀地址

        // todo  根据拼装好的服务url 调用起接口地址
        System.out.println("post方式调用其他服务接口地址:" + sericeUrl);
    }
}

给serviceUrl加上关键字之后serviceUrl += "suffix_Url";就会出现编译报错:

   

所以我们通过添加final关键字之后,serviceUrl这个对象引用就不允许再指向其他对象了,起到安全发布这个String类型的对象,保证该对象的引用不会指向其他对象,这其实就是一种策略,具体在下文的线程安全策略部分为详细介绍到。这里只是拿我曾经写过的代码作为例子来讲解发布对象中存在的不安全的场景,大家可以在网上找下安全发布对象的文章,里面的例子都是一样的,网上的例子是对一个发布出来的数组的内容修改而导致的不安全发布对象的情况,注意这是对对象内容的修改,后面的安全策略会提到具体的策略。

      不安全发布对象其实有很多实际当中的例子出现,特别是多线程并发的环境中,当出现之后就不好查找原因而且会出现莫名奇妙的问题,所以大家在今后的编程当中要记得有这方面的考虑,上面我举的是一个非常简单的例子,实际场景当中就会复杂多样了,大家可以好好思考思考在实际场景当中会出现的情景,提升自己对问题考虑的广度和深度。

      不过我还要补充一点就是上面说的这类不安全发布的情景是从这个对象作用以及整个项目的上下文来考虑的,那假如对象发布之后,可以被修改内容的话就不存这类不安全发布这一说法了,那在多线程并发的程序中考虑的就是线程安全问题了,所以具体分析还是要根据实际场景来考虑具体的问题!另外还有一类不安全发布对象的场景就是下面讲的对象逸出问题。

3、什么是对象逸出

       对象逸出就是一个类在还没被正确构造完成之前就被发布出去了,说直白一点就是类的构造函数还没执行完这个实例对象就被发布出了,我开始也不知道在什么情景下面会出现这样的问题,而且这样的情景出现概率也不高,至少到目前为止我还没写过这样的代码,我把网上千篇一律的例子再加以改造之后给大家看看:

注意注释部分也仔细看看哈!Escap类:

public class Escape {

    private int thisCanBeEscape = 0;

    public Escape () {
        /**
         * 在执行Escape这个类的构造函数的里面实例该类的非静态内部类
         * 实例完非静态内部类后可能还会有后续的成员属性thisCanBeEscape赋值操作
         */
        new InnerClass();
        this.thisCanBeEscape = 22;
    }

    public void esacpeMethod(){
        System.out.println("Escape的成员属性的值:" + thisCanBeEscape);
    }

    public int getThisCanBeEscape() {
        return thisCanBeEscape;
    }

    private class InnerClass {

        public InnerClass() {
            /**
             * 非静态内部内的实例是和外围类的实例一一对应的,具体可以看相关的内容
             * 在这里访问了外围类Escape的成员属性thisCanBeEscape,可是在这里输出的值是0,这里就是问题所在
             */
            System.out.println("非静态内部类访问外围类属性的值:" + Escape.this.thisCanBeEscape);

            /**
             * 这里多加一步操作,让大家更加直观的体会一下对象逸出造成的的问题
             */
            Other other = new Other();
            other.setEscape(Escape.this);
            other.method();
        }
    }

    public static void main(String[] args) {
        new Escape().esacpeMethod();
    }
}

Other类:

public class Other {

    private Escape escape;

    public Escape getEscape() {
        return escape;
    }

    public void setEscape(Escape escape) {
        this.escape = escape;
    }

    public void method(){
        /**
         * 假如这里进行了把数据库的插入或者更新操作的话,那就会存在错误的值了!
         *
         * 其实这里可以新建一个线程去执行下面的输出语句,你会发现更深刻的实际效果,我这里就不写啦,我其实是写了的(下面注释代码),但是又撤这种简单的方式了,
         * 大家兴趣可以尝试一下看看输出的结果然后再考虑一下原因
         */
        
        /*Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("在其他地方拿到这个类实例之后去处理后续的流程,其中访问Escape的成员属性的值:" + escape.getThisCanBeEscape());
            }
        });
        thread.start();*/

        System.out.println("在其他地方拿到这个类实例之后去处理后续的流程,其中访问Escape的成员属性的值:" + escape.getThisCanBeEscape());
    }
}

输出结果:

非静态内部类访问外围类属性的值:0
在其他地方拿到这个类实例之后去处理后续的流程,其中访问Escape的成员属性的值:0
Escape构造完成之后的成员属性的值:22

        看出对象逸出所造成的问题没?如果还在构造Escape的时候就让还没构造完成的对象发布出去后,使用的地方(当然线程在使用,可能是当前线程、也可能是新建的其他线程)所拿到的对象其实是没有完全构造完成的。另外我在Other类里面有一段注意事项,大家可以把代码贴到IDEA然后新建一个线程去执行Other类的method方法中输出语句(我上面有代码但是注释了),然后输出看看结果,我这里不详细说明原因啦,大家尝试自己手动操作并且从结果当中思考原因!

       上面的例子我就不详细描述了,大家仔细体会对象逸出带来的问题!下面我们就针对前文介绍的不安全发布对象的两类情景类说说如何做到安全发布对象。

4、如何做到安全发布对象

       上文说了不安全发布对象的两类情景,我们现在就来针对这两类分别来分析一下如何做到安全发布对象。

        首先是上文说的第一类,不允许随意修改的发布出来的共享对象应该将对象的引用保存到某个正确构造的final类型域中,保证不能被其他地方或者说线程随意修改内容,其实这块和下文线程安全策略中的不可变对象策略一样的意思,对不同类型的对象设置为不可变对象的方式是不一样的,也有多种方式,在下文会详细描述。

        另外一类就是对象逸出场景,通过上面例子来看逸出的情况是在实例对象的构造函数当中容易出现对象逸出问题的,在实际的工作项目当中可能会存在某个实例在构造的时候(也就是在执行构造函数的时候)会有一系列复杂的初始化、业务流程需要执行等操作,这个时候就会存在对象逸出的可能性,所以我们就这种场景来看如果要避免对象逸出的话肯定是需要保护实例的构造函数吧?也就是在执行构造函数的时候注意对象逸出这个问题。这一块我看网上都是和单例模式挂上钩的,其实我也是挺纳闷,单例模式确实是保护了构造函数,但是其主要的作用在于项目启动之后保证被单例实现的类在整个项目周期只有唯一的一个实例,不需要频繁创建而给系统内存和垃圾回收带来压力,既然网上都这么挂钩我在这里也顺便介绍一下单例模式吧,前提说明一下不是为了避免对象逸出问题而采用单例模式,单例模式的使用主要看具体的实际场景。

       单例模式的多种实现方式我就不全部贴出来了,我在本文将详细分析多线程并发和指令重排序两种情况下导致的问题,

我们先看下懒汉模式:

/**
 * 懒汉模式
 * 单例实例在第一次使用时进行创建
 */
public class SingletonExample1 {

    // 将构造函数私有化,外部无非直接实例该类
    private SingletonExample1() {}

    // 单例对象
    private static SingletonExample1 instance = null;

    // 静态的工厂方法
    public static synchronized SingletonExample1 getInstance() {
        if (instance == null) {
            instance = new SingletonExample1();
        }
        return instance;
    }
}

        在单线程或者说不是高并发的环境下按照上面的方式实现单例模式确实没什么问题,但是如果在高并发的情况下就会出现被实例多次的问题。在我其他的文章里面有过多次分析高并发情况下的线程安全例子,希望大家在分析线程安全问题的能力也在不断的提升。我们继续来分析:假如现在有A和B两个线程,A跑到了if条件里面并且在还没执行new SingletonExample1()时CPU就切换到线程B了,那线程B判断instance == null 肯定是成立的并且也进入了if条件里面,从而造成该类被实例了两次以上。看了我一篇文章的同学肯定可以很快的找到解决办法,就是加同步关键字synchronize,看下面例子:

/**
 * 懒汉模式 ->  加同步机制
 */
public class SingletonExample2 {

    // 将构造函数私有化,外部无非直接实例该类
    private SingletonExample2() {
    }

    // 单例对象
    private static SingletonExample2 instance = null;

    // 静态的工厂方法
    public static SingletonExample2 getInstance() {
        synchronized(SingletonExample2.class) { // 同步锁 或者加在方法上
            if(instance == null) {
                instance = new SingletonExample2();
            }
        }
        return instance;
    }
}

       上面的例子是在方法体里面加同步快,其实也可以加在方法上面,通过改造加同步代码块之后SingletonExample1的线程安全问题就解决了,但是大家分析一下getInstance在高并发被多个线程并发调用的情况下面会有什么新的问题?我们同样来分析假如现在有100个线程并发调getInstance,最先的执行到同步代码块的线程拿到锁之后进入到同步代码块里面,但是还没执行完CPU就切换到其他的99个线程当中,极端的情况下99个线程都尝试去拿锁但是最终都失败并且都被阻塞在同步代码块的位置,即使第一个线程执行完了,其他被阻塞的线程还会存在漫长的尝试拿锁以及再次被阻塞的情况,如果此时又有线程再进来都统统要加入这段竞争激烈的过程,这就极大的延长了系统响应时间和影响系统的性能了,如何解决这个问题呢?看下面例子:

/**
 * 懒汉模式 -> 双重同步锁单例模式
 */
public class SingletonExample3 {

    // 将构造函数私有化,外部无非直接实例该类
    private SingletonExample3() {
    }

    // 单例对象
    private static SingletonExample3 instance = null;

    // 静态的工厂方法
    public static SingletonExample3 getInstance() {
        if (instance == null) { // 双重检测机制, 在同步代码块之前添加一层判断,判断是否需要进入到同步代码块
            synchronized (SingletonExample3.class) {
                if (instance == null) {
                    instance = new SingletonExample3();
                }
            }
        }
        return instance;
    }
}

       这就是双重检测机制,通过SingletonExample2的例子再引出这个例子相信能让大家更加深刻的理解,除了在这的应用,其实大家在其他实际的并发编程当中应该也多考虑考虑SingletonExample2的问题,尽可能让多线程不参与竞争共享资源(这里就是锁资源)。

       线程安全问题解决后另外我们再来分析因在指令重排序而导致的问题,当然指令重排序会造成线程安全问题也是在多线程并发情况下出现,我在上一篇文章有介绍到,现在就再用单例模式的例子讲一讲,我们拿SingletonExample3的代码来分析,首先大家要明白的时候instance = new SingletonExample3();这句代码其实是分三条指令执行的:

 1、memory = allocate() 分配对象的内存空间
 2、ctorInstance() 初始化对象
 3、instance = memory 设置instance指向刚分配的内存

如果指令重排序了之后,就可能成了下面这种情况:

 1、memory = allocate() 分配对象的内存空间
 2、instance = memory 设置instance指向刚分配的内存
 3、ctorInstance() 初始化对象

那我们再来分析一下假如有线程A和B,线程A顺利执行到上面指令的2,那instance是不是就不为空了,但是线程A还没执行3的时候CPU就切换到了B,此时B进入到方法判断instance = null不成立就会直接返回instance,这样在线程A没执行完3之前线程B使用了instance的话就会出现问题!这就是指令重排序之后所带来的问题,解决方法就是给变量加volatile关键字防止指令重排序,上篇文章有讲到过:

private volatile static SingletonExample3 instance = null;

最后我贴出网上总结的安全发布对象集中方法,其实就是我上面讲的所有内容:

1、将对象的引用保存到某个正确构造的final类型域中
2、将对象的引用保存到volatile类型域或者AtomicReference对象中
3、在静态初始化函数中初始化一个对象引用
4、将对象的引用保存到一个由锁保护的域中

 

二、线程安全策略

       我先罗列线程安全策略的方式:不可变对象、线程封闭、同步容器和并发容器,这里不知道啥意思没关系,我会逐个介绍,请往下看!

1、不可变对象

       不可变对象具体指的是:不可变对象的引用和不可变对象的内容,那如何做到这两点呢?

       首先我们要明白的是不可变对象的这个对象肯定是结合实际需求所决定不允许改变的,然后我们来说如何做到不可变,其实可以参考String类:

  • 可以采用的方式是将类声明为final,将所有成员都声明为私有的,对变量不提供set方法:这样能保证外部不能设置对象成员的值。
  • 将所有可变成员声明为final:这样如果是基本类型的话初始化的值就不能再被改变,如果是对象引用就不能再被重新引用到其他的对象,关于final在下面会详细讲到。
  • 通过构造器初始化所有成员,进行深度拷贝,在get方法中不直接返回对象本身,而是返回对象的拷贝:这样即使使用线程进行了更改也是修改“副本”的内容,其他想要使用的线程拿的还是原本的对象内容。

 

1.1、final关键字

      final可以修饰的有如下三处,图片已经直观的说明一切(文末有本文所有图片的参考来源):

修饰类和修饰方法两种方式我就不用实际的例子来演示了,文末主要来看修饰变量:

/**
 * 假如项目需要一个用来表示中国省份的类
 */
public class FinalTest1 {

    // 基本数据类型:表示省份的数量(这里就写10个作用例子)
    private final static int provinceCount = 10;

    // String类型的变量:表示该类的名称
    private final static String str = "中国省份信息工具类";

    // 其他类型的变量:用来表示所有省份对应的省会城市名称
    private final static Map<String, String> map = new HashMap<>();

    static { // 这里用静态代码块来初始化map的内容
        map.put("湖南省","长沙市");
        map.put("广东省","广州市");

        // ...  这里就写两个做例子
    }

    public static void main(String[] args) {

        /**
         * ①被final修饰的基本类型遍历无法改变其值
         * ②被final修饰的对象引用无法指向新的对象
         * 下面三行都会编译报错
         */
        // ① a = 2;   // 肯定不允许修改省份的个数嘛,因为个数是确定的了
        // ② str = "3";  // 肯定不允许修改工具类的名称嘛
        // ② map = new HashMap<>();  // 这里也不许把本身初始化好的数据再抹掉嘛

        /**
         * 被final修饰的对象引用无法指向新的对象,
         * 但是能够修改其指向的对象的内容
         */
        map.put("湖南省","武汉市");

        // 假设这里被其他线程再次利用的时候已经是被修改过的值了
        System.out.println(map.get("湖南省"));
    }
}

       上面的例子通过加final修饰引用类型时,虽然不能将引用再指向别的对象,但可修改该对象的内容,就如上面的map对象,如果某个线程不小心修改了其中的值之后,再被该线程的后续操作或者其他线程使用的时候就拿的是一个错误的内容了!所以final关键字不能保证的是所修饰的引用类型所有引用的对象的内容不可变。

1.2、Collections.unmodifiableXXX

        除了final可以定义不可变对象,java提供的Collections类,也可定义不可变对 象,Collections.unmodifiableXXX传入的对象一经初始化便无法修改,XXX可表示Collection、List、Set、 Map等,谷歌提供的Guava类,也有类似的功能,ImmutableXXX,XXX同样可表示Collection、List、Set、Map等,用这种方式就可以保证对象的内容在初始化之后不能再被修改(当然这里都是针对集合类型的):

public class FinalTest2 {

    // 其他类型的变量:用来表示所有省份对应的省会城市名称
    private static Map<String, String> map = new HashMap<>();

    static { // 这里用静态代码块来初始化map的内容
        map.put("湖南省","长沙市");
        map.put("广东省","广州市");

        // 初始化内容之后在这里添加了一步, 不过要正确执行这一步的话上面的final关键字需要去掉,否在也会出现编译异常
        map = Collections.unmodifiableMap(map);
    }

    public static void main(String[] args) {


        /**
         * 这里进行对内容的修改,编译过长当中是没有问题的
         */
        map.put("湖南省","武汉市");

        System.out.println(map.get("湖南省"));
    }
}

编译是没有问题,但是运行的时候就会报错:

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
    at CSDN.Final.FinalTest2.main(FinalTest2.java:26)

这样也就做到了限制对象内容的不可变

具体可以看下源码会发现实现Map相关修改的方法都是直接抛出异常不跟你多说:

 

2、线程封闭

       我自己对线程封闭的解释:将对象封闭在只有对应使用这个对象的线程范围内,其他任何线程均无法感知到,线程封闭策略也是在特定的实际需求当中考虑的,所以还是要根据实际的情况来决定是否采取这种策略,我们用一张图描述实现方式:

第二点堆栈封闭很好理解,我们在开发中局部变量并不用关心线程安全问题,就比如执行某个对象的方法的时候,方法里面的变量是在每个线程执行该方法时新建的,咱们着重讲下第三点ThreadLocal,其实这也是面试当中经常被问到的内容。

 

ThreadLocal应用

       ThreadLocal介绍:ThreadLocal是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。

      看源码是学习别人写好的工具类、框架的最好方式,不管将来使用还是面试的时候被面到,看完看懂源码都能解答所有的问题,所以我建议大家好好看下ThreadLocal的源码,我贴一张ThreadLocal和Thread的类图,有助于大家理解源码(图片来源于书本:《码出高效:Java开发手册》,有兴趣的同学可以购买或者下载电子版阅读,里面有对ThreadLocal的详细讲解,下面ThreadLocal例子也是参考本书):

       在本篇文章我不长篇大论讲解具体ThreadLocal的源码以及有关ThreadLocal源码涉及到的引用类型相关内容(文章篇幅以及很长啦),对ThreadLocal我更想讲述它的实际应用场景。

       LOL这个游戏应该很多人都玩过吧?开局十个人,每个人持有的初始数据有:金币数:初始500、杀敌数:0、助攻数:0、死亡数:0(咱们就列举这些吧,其实还有好多初始数据),进入到召唤师峡谷后十个人就假设为十个启动的线程,那这些初始数据怎么与每个线程绑定呢?如果给每个线程都写死这些初始数据,那如果要把初始金币数调整的话不是要对每个线程持有的数据都重新调整一遍?如果将这些初始的数据设置为共享变量的话那在整局游戏当中就会存在线程安全问题而导致数据不准确,所以这个时候我们就会想到能不能构造一个对象,这个对象设置为共享变量并且统一设置初始值,但是每个线程对这个值的修改都是相互独立的。那这个时候就可以考虑用ThreadLocal了!开局每个线程拿对应的共享变量,然后整局游戏过程中,对这些数据的操作都是与其他线程独立的,到最后游戏结束后再来结算每个英雄线程的各项数据。咱们来看下代码例子(建议先花点时间看看源码):

public class LOLGame {
    private static final ThreadLocal<Integer> GOLD_COINS_NUMBER = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue(){
            return 500;  // 初始金币数500
        }
    };

    private static final ThreadLocal<Integer> KILLED_NUMBER = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue(){
            return 0;  // 初始杀敌数
        }
    };

    private static final ThreadLocal<Integer> ASSIST_NUMBER = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue(){
            return 0;  // 初始助攻数
        }
    };

    private static final ThreadLocal<Integer> DEATHS_NUMBER = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue(){
            return 0;  // 初始死亡数
        }
    };

    private static class Player extends Thread{
        @Override
        public void run() { // 每个玩家启动之后进行的操作
            Random random = new Random();
            // 进行一次装备购买
            GOLD_COINS_NUMBER.set(GOLD_COINS_NUMBER.get() - RANDOM.nextInt(500));// 生产随机数在500范围内
            // 进行了杀敌
            KILLED_NUMBER.set(KILLED_NUMBER.get() + random.nextInt(10));
            // 遭到了被杀
            DEATHS_NUMBER.set(DEATHS_NUMBER.get() + random.nextInt(10));
            // 得到了助攻
            ASSIST_NUMBER.set(DEATHS_NUMBER.get() + random.nextInt(10));

            // 最后游戏结束后进行数据结算
            System.out.println( getName() + " 金币数 :" + GOLD_COINS_NUMBER.get());
            System.out.println( getName() + " 杀敌数 :" + KILLED_NUMBER.get());
            System.out.println( getName() + " 死亡数 :" + DEATHS_NUMBER.get());
            System.out.println( getName() + " 助攻数 :" + ASSIST_NUMBER.get());

            // 记得移除本局的数据,每个线程执行完之后,一定要记得移除对应的数据!原因大家查资料哈
            GOLD_COINS_NUMBER.remove();
            KILLED_NUMBER.remove();
            DEATHS_NUMBER.remove();
            ASSIST_NUMBER.remove();
        }
    }

    public static void main(String[] args) {
        for(int i = 0; i < 10; i ++){ // 开局创建十个线程去跑
            new Player().start();
        }

    }
}

上面例子中在每个线程执行任务的过程中没有显示的先set值,而是在构造ThreadLocal实例的时候重写了initialValue()方法来定义初始值,重写的initialValue()不是在构造实例的时候就会立即执行,而是在线程执行get()方法的时候会执行到,看源码:

public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

线程首次来get值的时候是没有对应数据类型的ThreadLocalMap,这个时候会去执行setInitialValue()方法:

private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

这个方法里面首先就是去拿初始化的值,这个时候就会执行我们重写的initialValue()拿到定义好的初始值。

       相信上面这个例子能让大家更加深刻的体会到ThreadLocal的引用场景了吧,不过大家要注意一点的就是ThreadLocal是针对共享变量(不可变对象,上面例子Integer是不可变对象)的更新问题,它是无法解决共享对象(可变对象)的更新问题,我们来看个例子:

public class VariableObject {
    private static final StringBuffer INIT_VALUE = new StringBuffer ("init");

    private static final ThreadLocal<StringBuffer> buildBuffer = new ThreadLocal<StringBuffer>(){
        @Override
        protected StringBuffer initialValue() {
            return INIT_VALUE;
        }
    };

    private static class AppendStringThread extends Thread{
        @Override
        public void run() {
            StringBuffer stringBuffer = buildBuffer.get();// 拿到当前线程的StringBuffer数据
            for(int i = 0; i < 10; i++){ // 进行十次添加字符串
                stringBuffer.append(getName() + i);
            }

            System.out.println(getName() + " 操作后的结果" + stringBuffer.toString());
            System.out.println();
        }
    }

    public static void main(String[] args) {
        for(int i = 0; i < 10; i ++){ // 开局创建十个线程去跑
            new AppendStringThread().start();
        }

    }
}

可以看到结果是杂乱无章的,多个线程其实操作的都是一个StringBuffer实例对象。

 

ThreadLocal副作用

       ThreadLocal可能存在两副作用:读脏数据和导致内存泄漏。这两个问题通常是在线程池中使用ThreadLocal而引发的,因为线程池有线程复用和内存常驻两个特点,大家好好理解线程池这两个特点再结合实际当中ThreadLocal时是不是会出现脏数据和内存泄漏,咱们来分析分析。

        脏数据:由于线程池中的核心线程是会长期存活并且会复用,假如线程池有个线程A,在首次执行任务的时候,进行了对ThreadLocal的set()操作,这个时候线程A就持有了对某个共享变量的私有数据,那假如此次任务执行完之后,没有进行remove操作的话,下次有新的任务进来再次选中线程A去执行的时候,倘若这个任务没有进行set()设置初始值,直接get()就会获取上次执行任务的数据,这就导致读到脏数据。我们来看个例子:

public class DirtyDataInThreadLocal {
    public static ThreadLocal<String> threadLocal = new ThreadLocal<>();
    public static boolean flag = true;

    private static class Task extends Thread{
        @Override
        public void run() {
            if(flag) { // 只让线程进行一次set赋值
                threadLocal.set(getName() + "设置的初始值");
                flag = false;
            }
            System.out.println(getName() + "拿到的threadLocal的值:" + threadLocal.get());
        }
    }

    public static void main(String[] args) {
        // 设置只有一个线程的线程池,这样每次让线程池执行任务的时候就都是同一个线程在操作
        ExecutorService singlePool = Executors.newFixedThreadPool(1);

        for(int i = 0; i < 2; i++){
            singlePool.execute(new Task());
        }
    }
}

执行结果如下:

Thread-0拿到的threadLocal的值:Thread-0设置的初始值
Thread-1拿到的threadLocal的值:Thread-0设置的初始值

       内存泄漏:在源码注释中提示使用static 关键字来修饰ThreadLocal。在此场景下,寄希望于ThreadLocal 对象失去引用后, 触发弱引用机制来回收Entry 的Value 就不现实了。在上例中,如果不进行remove() 操作, 那么这个线程执行完成后,通过ThreadLocal 对象持有的String 对象是不会被释放的。

       解决以上两个问题的办法就是每次线程用完ThreadLocal时,调用remove()方法清理。对ThreadLocal的描述就到这啦,如果上面的内容不是很懂,就先花时间好好看看ThreadLocal源码!

 

3、同步容器

注意Collections.synchronizedXXX的用法,可以将线程不安全的容器类转换成线程安全的类。

 

4、并发容器

       同步容器和并发容器我不做过多的例子编写,大家可以编写程序验证在多线程并发环境下对这些容器的操作时哪些是线程安全哪些是非线程安全的容器类。

 

 

参考文献:

https://www.cnblogs.com/sbrn/p/9007470.html

②《码出高效:Java开发手册》

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值