保障线程安全的设计

目录

无状态对象

有状态和无状态的区别:有状态-会存储数据、无状态-不会存储数据

对象就是操作和数据的封装(对象 = 操作 + 数据),对象所包含的数据就被称为该对象的状态,它包含对象的实例变量和静态变量中的数据,也有可能包含对象中引用其它变量的实例变量或静态变量。如果一个类的实例被多个线程共享不会存在共享状态,那么则称其为无状态对象。

这个类的对象是一个有状态对象
class A{
  Integer age;
}

这个类的对象中会引用其它有状态的对象所以它是一个由状态对象
@Component
class B{
  @Autowire
  private A a;  
}

只有操作没有状态 - 无状态
class C{
  public void test(){
    ......
  }
}

本身和引用都是无状态 - 无状态
@Component
class D{
  @Autowire
  private C c;
}
复制代码

一个类即使不存在实例变量或静态变量仍然可能存在共享状态,如下

enum Singleton{
  INSTANCE;
  
  private EnumSingleton singletonInstance;
  
  Singleton(){
    singletonInstance = new EnumSingleton();
  }
  
  public EnumSingleton getInstance(){
    //对singletonInstance做一些配置操作
    //例如 singletonInstance.num++;
    return singletonInstance;
  }
}

class SingletonTest{
  public void test(){
    Singleton st = Singleton.INSTANCE;
    st.getInstance();
  }
}
复制代码

enum的INSTANCE只会被实例化一次,所以如果没有注释所对应的操作,这就是一个完美的单例,且其它类对它的引用都是无状态的。但是如果在getInstance方法中对singletonInstance变量有所操作,那么在多线程环境中singletonInstance会出现线程安全问题。下面的例子这个问题更明显,num变量就是一个共享状态的变量。

enum Singleton{
  INSTANCE;
  
  private int num;
  
  public int doSomething(){
    num++;
    return num;
  }
}

class SingletonTest{
  public void test(){
    Singleton st = Singleton.INSTANCE;
    st.doSomething();
  }
}

复制代码

还有一种情况是静态变量的使用,静态变量与类(class)直接关联,不会随着实例的创建而改变,所以当Test没有实例变量和静态变量的情况下,在方法中通过类名直接操作静态变量,仍然会造成线程安全问题,即Test中存在共享状态变量的调用。

class A{
  static int num;
}

class Test{
  public void doSomething(){
    A.num++;
  }
}
复制代码

总结:无状态对象肯定是不包含任何实例变量或者可更新静态变量(包括来自相应类的上层类的实例变量或者静态变量)。但是,一个类不包含任何实例变量或者静态变量却不一定是无状态对象。

使用无状态类和只有静态方法的类

Servlet类就是无状态对象的典型应用,Servlet一般被web服务器(tomcat)托管,控制它的创建、运行、销毁等生命周期,一般一个Servlet类在web服务器中只会有一个实例被托管,但是它会被多个线程请求访问,并且,其处理请求的方法service并没有锁修饰,如果这个类里面包含实例变量或者是静态变量就会产生线程安全问题,所以一般情况下Servlet实例是无状态对象。

不可变对象

不可变对象(Immutable Object)指的是一经创建就不可改变状态的对象。

不可变对象满足以下条件

  • 类是被final字段修饰,防止通过继承来改变类的定义

  • 所有成员变量需要使用final修饰,一方面保证变量不能被修改,另一方面保证final属性对其它线程可见时,必定是初始化完成的(有的博文写必须是private+final,其实final就保证了数据不可修改)。

  • 如果这个字段是可变的引用对象,需要用private修饰这个字段且不提供修改这个引用对象状态的方法。

  • 对象在初始化过程中没有逸出(防止对象在初始化过程中被修改状态(匿名类)):一个还没初始化完成的对象被其它线程感知到,这就被称作是对象逸出,从而可能导致程序运行错误。下面是可能导致对象逸出的方式。

    • 在构造器中将this赋值给一个共享变量

    • 在构造器中将this作为参数传递给其它方法

    • 在构造器中启动基于匿名类的线程

下面是一个不可变对象,正常情况下我们创建实例后就不能再改变它的状态,所以需要对他进行修改的话只能重新创建一个实例来替代它。

final class Score{
  final int score;
  private final Student student;
  
  public Score(int score,Student student){
    this.score = score;
    this.student = student;
  }
  
  public String getName(){
    return student.getName();
  }
}

class test(){
  ......
  public void update(){
    Score s = new Score(...);
  }
}
复制代码

不可变对象的使用会对垃圾回收的效率产生影响,既有积极的一方面影响,又有消极的影响。

负面:由于只要想对不可变对象做出更新,就得重新创建一个新的不可变对象,过于频繁的创建对象会增加垃圾回收的频率。

正面:一般来说对象中存在成员变量是对象引用的话,那么一般在可变对象中这个引用对象是在年轻代,而可变对象本身在老年代,但是不可变对象一般是引用对象处于老年代,而不可变对象本身处于年轻代。修改一个状态可变对象的实例变量值的时候,如果这个对象已经位于年老代中,那么在垃圾回收器进行下一轮次要回收(Minor Collection)的时候,年老代中包含这个对象的卡片(Card,年老代中存储对象的存储单位,一个Card的大小为512字节)中的所有对象都必须被扫描一遍,以确定年老代中是否有对象对待回收的对象持有引用。因此,年老代对象持有对年轻代对象的引用会导致次要回收的开销增加。

可以采用迭代器模式来减少不可变对象的内存空间占用注释1

线程特有对象

对于一个一个非线程安全的对象,每个访问它的对象都创建一个该对象的实例,每个线程只能访问自己创建的对象实例,这个对象实例就被称作为线程特有对象(TSO,Thread Specific Object)或线程局部变量。

ThreadLoacl<T>相当于线程访问其特有对象的代理,线程可以通过这个对象创建并访问各自线程的特有对象。

ThreadLocal实例为每个访问它的线程提供了一个该线程的线程特有对象。

方法 功能
public T get() 获取与该线程局部变量关联的当前线程的线程特有对象
public void set(T value) 重新关联该线程局部变量所对应的当前线程的线程特有对象
protected T initialValue() 该方法的返回值(对象)就是初始状态下该线程局部变量所对应的当前线程的线程特有对象
public void remove() 删除该线程局部变量与相应的当前线程的线程特有对象之间的关联关系

ThreadLocal的简单使用方法及源码分析

public class ThreadLocalDemo {

    public static void main(String[] args) {
        Thread t = new Thread(){
            @Override
            public void run() {
                A a = new A();
                a.testA();
                A.TL.remove();
            }
        };
        t.setName("t1");
        t.start();
    }
}
class A{
    final static ThreadLocal<String> TL = new ThreadLocal<String>(){
        @Override
        protected String initialValue() {
            return "A";
        }
    };

     void testA(){
        String str = TL.get();
        System.out.println("str = " + str);
        TL.set("B");
        str = TL.get();
        System.out.println("str = " + str);
    }
}
--------------------------------------
str = A
str = B

复制代码

如上我们在ThreadLocalDemo类中新建一个ThreadLocal实例且使用匿名类匿名类将initialValue方法重写。下面调试查看一下整个样例的流转方式。

在上图位置给get方法打一个断点

因为此线程对应的threadLocals是一个空值所以要先进入到setInitialValue方法对其进行初始化。

在这个方法中主要就是获取我们要代理的对象,如果在声明ThreadLocal的时候不重写initialValue()方法则在这个地方获取的是一个空值。然后就给当前线程创建一个ThreadlocalMap实例并写入元素,最后将对象实例返回,ThreadLocal.get()方法就获取到了值。

然后我们再进入set方法观察其中的流程

同样会先获取当先线程的threadlocals判断其是否为空,如果为空将会帮他初始化一个,并将set的参数存入到threadlocals,否则将直接将Threadlocal→value存入这个容器中。

set方法的流程基本就是向ThreadLocalMap的Entry数组中插入或修改我们这个ThreadLocal(TL)实例要代理的对象实例("B"),然后就是通过ThreadLocal(TL)的hashcode来确定这个对象实例在数组中的位置。

基于对treadLocalHashCode的追溯,我们发现其就是ThreadLocal类中一个自增的静态变量。

上面是对于相关的threadLocal实例在线程的存储条目数组中的查找,及插入替换方式:

  • 当查找到threadLocal有对应的条目时会直接将value替换

  • 当遍历的的时候发现了无效条目,将这个条目的key和value替换

  • 没有上述两种情况,在为null的槽位插入新的条目new Entry(key,value)

在插入新的条目后要对Entry数组进行遍历查找value为null的Entry,并将其对应条目置为null,然后判断Entry数组是否需要进行扩容操作。

当set结束后,再次调用get获取ThreadLocal代理的实例对象,因为之前当先线程的threadlocals已经被初始化并且存在当前ThreadLocal(TL)对象对应的Entry(第一次get的时候调用initValue初始化为A后面又set为B),所以可以直接获得目标代理的实例对象("B")。

一般线程局部变量都会被声明为静态变量,因为这样只会在类加载的时候被创建一次,如果声明为实例变量,那么每次创建一个类的实例这个线程局部变量都会被创建一次,这样会造成资源的浪费。

线程特有对象可能造成的问题

  • 数据退化与错乱问题

    如上图因为TL是一个静态变量,所以每次new Task()是不会对TL重新初始化,就会导致当Thread-A在执行完Task-1后再执行Task-2时,因为执行它们的时同一个线程,所以,它们通过TL获取到的Map对象实例都是同一个线程特有对象,这就导致Task-2可能会获取到Task-1操作的数据,这样就可能造成数据错乱问题。

    所以在这种情况下,我们需要在获取到ThreadLocal代理的对象实例后,需要先对其做一些前置操作,如对上面的HashMap对象实例进行清空。

    TL.get().clear();
    复制代码

    很多时候我们也会用ThreadLocal传递一些数据,例如:在ThreadLocal中存储token等信息,但是为了不让下一个任务获取到这次的请求token信息,需要在拦截器的后置处理器中将其remove掉,此操作就是为了防止数据错乱!

  • 内存泄漏问题

    内存泄漏→指的是一个对象永远不能被虚拟机垃圾回收,一直占用某一块内存无法释放。内存泄漏的增加会导致可用的内存越来越少,甚至可能导致内存溢出。

    由之前的ThreadLocal源码分析可知,我们将ThreadLocal对象实例和它代理的对象以key-value的方式存入到Thread对应的ThreadLocalMap中,而真正存储这个key-value的是ThreadLocalMap一个的Entry数组,也就是我们会将key-value封装成Entry对象。

    由上图可知Entry对于ThreadLocal实例的引用是以个弱引用,在没有其它对象强引用时ThreadLocal实例会被虚拟机回收掉 ,这时候Entry中的key就会编程null,也就是此时Entry会变成一个无效条目。

    另外,Entry对于线程特有对象的引用是强引用,所以如果Entry变成了无效条目后,这个线程特有对象由于强引用的关系并不会被回收,也就是说,如果无效条长时间不会被清理或者永远不被清理那么就会对内存长时间的占用,营造出内存泄漏的现象。

    那么无效条目什么时候会被清理呢?之前ThreadLocal的源码分析中,ThreadLocal.set()操作(对ThreadLocalMap的插入)时可以引发失效Entry的替换或者清理,但是如果一直没有对这个线程的ThreadLocalMap的插入的操作的话无效条目就会一直占用内存。所以为了防止这种现象的发生,我们需要养成一个良好的习惯,在每次对ThreadLocal对象使用完毕后,手动的调用ThreadLocal.remove方法来清理无效条目(一般在线程结束后调用)。


  1. 后面补

猜你喜欢

转载自juejin.im/post/7094665495135125511