多线程及池化技术——线程安全探究


前言

当多个线程访问一个共享变量时,如何去考虑一个线程在使用这个变量时的调度和执行,需不需要进行同步操作,使用怎样的同步操作会达到期望的结果,这就涉及到线程安全问题,接下来我们就针对线程安全相关的一些问题和知识点一探究竟。
本文基于JDK1.8源码


一、什么是线程安全问题?

简单来说,就是在多线程环境下,一个变量的读写操作,会受到不同线程之间的影响,导致变量数据有误差出入,这就产生了线程安全问题。
如果对一些变量只有读操作的话,一般来说,这些变量是线程安全的,但如果多线程同时执行写操作时,就不得不考虑线程同步,不然很可能触及到线程安全问题。

	// 一共10张票
    private static int count = 10;

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newScheduledThreadPool(2);
        for (int i = 0; i < 2; i++) {
            executorService.execute(() -> lastTickets());
        }
        executorService.shutdown();
    }
    public static void lastTickets() {
        while (count > 0) {
            log.info("剩余 {} 张票", count);
            count --;
        }
    }

执行结果:

--pool-1-thread-1-- [com.jikeh.test.ThreadSafeTest] 剩余 10 张票 
--pool-1-thread-2-- [com.jikeh.test.ThreadSafeTest] 剩余 10 张票 
--pool-1-thread-2-- [com.jikeh.test.ThreadSafeTest] 剩余 8 张票 
--pool-1-thread-1-- [com.jikeh.test.ThreadSafeTest] 剩余 9 张票 
--pool-1-thread-2-- [com.jikeh.test.ThreadSafeTest] 剩余 7 张票 
--pool-1-thread-1-- [com.jikeh.test.ThreadSafeTest] 剩余 6 张票 
--pool-1-thread-2-- [com.jikeh.test.ThreadSafeTest] 剩余 5 张票 
--pool-1-thread-1-- [com.jikeh.test.ThreadSafeTest] 剩余 4 张票 
--pool-1-thread-2-- [com.jikeh.test.ThreadSafeTest] 剩余 3 张票 
--pool-1-thread-1-- [com.jikeh.test.ThreadSafeTest] 剩余 2 张票 
--pool-1-thread-2-- [com.jikeh.test.ThreadSafeTest] 剩余 1 张票 

这是一个简单的抢占数据的线程安全问题,变量count会在两个线程之间做变动,且每次执行结果都不相同,不是我们希望看到的,对于线程本身来讲是不安全的。

线程的安全性其实是一个相对有条件的概念,比如Vector或者ConcurrentHashMap,其中的方法都进行了同步处理,是支持线程安全的,但是如果是线程间的组合操作时就会出现问题,比如线程1去写数据,线程2去读数据并且删除其中某些数据,然后线程1再去读一个线程2删除的数据,就会出现线程不安全情况。
其实线程安全性没有一个绝对的接受范围,我们要分清楚线程安全的一些分类,并且尽量的记录线程产生的安全行为,这样可灵活运用哪些线程安全类的适用场景。

二、线程安全分类

1 不可变

  • 不可变(Immutable Object)一定是线程安全的,所以不需要采取任何线程安全措施。
  • 只要能正确构建一个不可变对象,该对象永远不会在多个线程之间出现不一致的状态。
  • 多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

不可变的几种方式:

  1. 如果变量是基本数据类型,用final修饰,则成为常量,不可修改其值。
  2. 如果变量是引用类型,该变量创建后不能对其状态有影响。
  3. String是特殊类型,不可变的,对其进行substring()、replace()、concat()等操作,返回的是新的String对象,原始的String对象的值不受影响。而如果对StringBuffer或者StringBuilder对象进行substring()、replace()、append()等操作,是直接对原对象的值进行改变。
  4. 使一个对象是不可变的,内部的变量也要用final定义,并且要初始化,不然编译异常。

常见的不可变的类型:

  1. final关键字修饰的基本数据类型
  2. 枚举类型、String类型
  3. 常见的包装类型:Short、Integer、Long、Float、Double、Byte、Character等
  4. 时间类型:jdk1.8+日期/时间API中的所有类都是不可变的,LocalDate、LocalTime等
  5. 大数据类型:BigInteger、BigDecimal
  6. 集合,可以用Collections.unmodifiableXXX();声明一个不可变集合,jdk1.9可以直接使用Map.of()、List.of()或者Set.of()等

延伸:原子类 AtomicInteger 和 AtomicLong 等原子类型是可变的,其中用Unsafe.compareAndSwapInt保持原子安全性

不可变集合中,实则是对原集合的拷贝赋值:

 	static class UnmodifiableList<E> extends UnmodifiableCollection<E>
                                  implements List<E> {
        private static final long serialVersionUID = -283967356065247728L;

        final List<? extends E> list;

        UnmodifiableList(List<? extends E> list) {
            super(list);
            this.list = list;
        }

如果对不可变集合进行新增,修改,清除操作时,直接会抛异常UnsupportedOperationException:

	List<String> strings = Collections.unmodifiableList(Lists.newArrayList());
    strings.add("123");
    log.info("======={}======", strings.toString());
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.util.Collections$UnmodifiableCollection.add(Collections.java:1055)
	at com.jikeh.test.ThreadSafeTest2.main(ThreadSafeTest2.java:44)

2 绝对线程安全

是一种类的约束在多线程中都保持一种的严谨规范,不论是引用还是值,线程不需要在对其进行额外的同步策略,所以要求很严格,在java中大多数的线程安全类都不属于绝对安全,比如上文提到的Vector还有ConcurrentHashMap等。

	private static Vector<String> vector = new Vector();
	for (int j = 0; j < 10; j++) {
        for (int i = 0; i < 10; i++) {
            vector.add(i + "");
        }
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < vector.size(); i++) {
                    System.out.println("获取vector的第" + i + "个元素: " + vector.get(i));
                }
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (vector) {
                    for (int i=0;i<vector.size();i++){
                        System.out.println("删除vector中的第" + i+"个元素");
                        vector.remove(i);
                    }
                }
            }
        }).start();
    }

两个线程同时访问操作一个Vector集合,出现异常ArrayIndexOutOfBoundsException,

Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 7
	at java.util.Vector.remove(Vector.java:834)
	at com.jikeh.test.ThreadSafeTest$MyThread1.run(ThreadSafeTest.java:93)
	at java.lang.Thread.run(Thread.java:748)

3 相对线程安全

相对线程安全需要保证对该对象的单个操作是线程安全的,在线程并发执行时可以保证共享变量是相对安全的,在必要的时候可以使用同步措施实现类绝对的线程安全。
大部分的线程安全类都属于相对线程安全,如Java容器中的Vector、ConcurrentHashMap、通过Collections.synchronizedXXX()方法包装的集合。

4 线程兼容

如ArrayList、HashMap、Set这些都是线程兼容类,也可称为线程安全兼容类。
线程兼容类不是线程安全的,但是可以通过正确使用同步在并发环境中安全地使用。

5 线程对立

线程对立是指不管是否调用了外部同步都不能在并发中安全的使用。
线程对立很少见,当类修改静态数据,而静态数据会影响在其他线程中执行的其他类的行为就出现了线程对立,如已经废弃了Thread的方法Thread.suspend()、Thread.resume(),还有System.setIn()和System.setOut()等。

附:枚举类型

枚举是在jdk1.5之后引入的一种类型,用于定义一些有规律的值的有限集合Enum,可以作为一个程序组件使用,并且Enum类型是线程安全的,这里说一下枚举类型为什么是线程安全的,先看个示例:

public enum ModuleEnum {
    key1("key1", "eat"),
    key2("key2", "walk"),
    key3("key3", "water"),
    key4("key4", "sleep");
}

反编译之后:

public final class com.jikeh.util.ModuleEnum extends java.lang.Enum<com.jikeh.util.ModuleEnum> {
  public static final com.jikeh.util.ModuleEnum key1;

  public static final com.jikeh.util.ModuleEnum key2;

  public static final com.jikeh.util.ModuleEnum key3;

  public static final com.jikeh.util.ModuleEnum key4;
}

根据源码我们看到ModuleEnum被final修饰,说明不能被子类继承,并且继承于Enum,内部的四个数据都被static final修饰为常量,Enum中有两个参数分别是name和ordinal,其中ordinal规定了每个常量的序号,不论常量数值怎么变动,序号是不变的,所以,不可变对象中不可变参数的枚举类型一定是线程安全的。

三、线程安全的实现

1 阻塞同步

阻塞同步也可称为互斥同步,是指在多线程并发共享数据时,同一时刻只能有一个线程使用,互斥是同步的一种策略手段,需要考虑到三个要素,操作系统原理里提到过的这三个概念:临界资源(Critical Section)、互斥量(Mutex)、信号量(Semaphore)
我们应该清楚同步和互斥之间的关系:

互斥是原因,同步是结果
互斥是方法,同步是目的

Java中,最基本的实现互斥同步的手段是synchronized关键字,其次是java.util.concurrent.locks.ReentrantLock

1.1 synchronized

这个关键字在jdk1.6之前挺重的,因其每次都会占用很大的时间、性能,并且没有实现自旋和轻量等机制,后来单独对synchronized做了很多的优化,我们也可以发现很多线程安全类里面都使用synchronized去对方法或者代码块进行了同步调用,目前看,synchronized在一些同步调用时是个不错的选择,我们看着源码来了解运行原理:

// 简单定义一个同步类
public class SysDemo {
    public SysDemo() {
    }

    public void test() {
		// 锁当前普通类实现同步
        synchronized(this) {
            System.out.println("process...");
        }
    }
}

反编译之后的字节码处理流程:

public class com.jikeh.po.SysDemo {
  public com.jikeh.po.SysDemo();
    Code:
       0: aload_0  // 开始执行
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return  // 完成

  public void test();
    Code:
       0: aload_0  // 开始执行
       1: dup  // 将锁的对象duplicate复制到堆栈
       2: astore_1  // 将赋值的对象信息放入local局部变量表的索引为1的位置中
       // 3-14行其实就是加锁同步的主要逻辑
       3: monitorenter  // 知识点来了,开始获取monitor指令,尝试获取对象锁
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3                  // String process...
       9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: aload_1  // 将存放在局部变量索引为1的位置的数据推送至栈顶
      13: monitorexit // 退出当前类的monitor指令
      14: goto          22  // 正常接收,也可直接return
      17: astore_2  // 如果再次进来一个对象则放在这里局部变量表的索引为2的位置中
      18: aload_1  // 将存放在局部变量索引为1的位置的数据推送至栈顶
      19: monitorexit  // 结束monitor指令
      20: aload_2  // 将存放在局部变量索引为2的位置的数据推送至栈顶
      21: athrow  // 并将异常信息返回给调用程序
      22: return // 正常返回
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}

以上是synchronized的处理流程,详细步骤也做了注释,需要说明一下的是下方的异常表(Exception table),异常表对于Code属性来说并不是必须存在的,有些像枚举类型就没有异常表。从异常表看出,会两条存在的异常信息,意思是:
如果4到14行出现异常,那就转到17行执行;如果17行到20行出现异常,那也转到17行执行,这里相当于阻塞的线程在自旋。

再说明步骤之前,我们先看下JDK-API官方给这monitorenter 和monitorexit 的两个指令的说明:

monitorenter

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:

If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

monitorexit

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

大致内容是说: 可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

我们再来看之前我们那段源码:

  1. JVM执行开始,首先执行monitorenter指令,判断是否可以获得对象锁,如果能获取到,那就上锁计数器+1,然后执行逻辑,
  2. 如果获取锁失败,那就进入阻塞状态,等待持有锁的线程释放给自己
  3. 最后执行monitorexit指令,锁计数器-1,一直减到0为止,然后释放,这里要说明一下,synchronized是支持可重入的,所以计数器可以>1

使用synchronized其实是使系统有点类似于单道处理系统,保证其可见性,顺序性,原子性,线程在执行过程中,会在执行态、阻塞态、就绪态之间不停的切换,而进程切换涉及到用户态和系统态所带来的性能消耗问题,为了避免来回切换,可以让线程在阻塞之前先自旋等待一段时间,超时未获取到锁才进入阻塞状态,这就是后续引入自旋的原理机制了。

1.2 ReentrantLock

public class ReentrantLock implements Lock, java.io.Serializable {
	// 使用同步类来进行所有的同步实施
	private final Sync sync;
}

从ReentrantLock类的定义我们可以看出,是实现了Lock接口的一个类,就会比synchronized有更多的操作手段,一般用这两者的比较来区别我们的具体使用:
相同点:同步互斥、线程可重入
不同点

  1. synchronized是原生语法层面上的互斥锁,ReentrantLock是API层面的互斥锁(lock(),trylock(),unlock())
  2. ReentrantLock可以通过含参构造器创建公平锁FairSync,synchronized只能是非公平锁
  3. ReentrantLock可以实现等待可中断,即长时间获取不到锁,可以放弃等待,转去处理其他事情
  4. ReentrantLock可以绑定多个条件,通过多次调用newCondition()方法即可,而synchronized的锁对象只能实现一个隐含条件

这时大家可能会认为ReentrantLock这么多优点是不是要大力使用啊,其实不然,jdk1.6之后,ReentrantLock对比synchronized的性能问题已经其卖点了。
在未来的性能改进中,虚拟机将会更加偏向于原生的synchronized,大家可以看下jdk1.8或者java11的代码,其实很多线程同步类都使用的synchronized,所以如果不是要用到ReentrantLock的高级功能,还是首推synchronized。

2 非阻塞同步

上文提到,阻塞同步是互斥的,一个在执行,另一个就在阻塞,这种采用的是悲观并发策略,这必然会带来性能的影响,别管再怎么优化也是受影响了,所以我们需要一种策略是非阻塞的同步,或者说是乐观并发,那就可以采用非阻塞同步。

2.1 乐观并发的特点

  • 先进行操作,如果不存在冲突(即没有其他线程争用共享数据),则操作成功
  • 如果有其他线程争用共享数据,产生了冲突,使用其他的补偿措施
  • 常见的补偿措施:不断尝试,直到成功为止,比如循环的CAS操作

非阻塞同步中常用的操作:

  1. 测试和设置(Test and Set)
  2. 获取并增加(Fetch and Increment)
  3. 交换(Swap)
  4. 比较并交换(Compare and Swap,即CAS)
  5. 加载链接/条件存储(Load Linked / Store Conditional,即LL/SC)

前三个比较古老,咱也没运用过不知道,咱也不敢说,现代操作系统基本都不用这三个了,最后一个加载链接是基于链接、运行、加载、存储地址,接触的少咱也不敢说,第四个才是非阻塞同步的重点,也是因为大多数的非阻塞同步都使用CAS策略实现的。

2.2 CAS机制

什么是CAS?

  • CAS,即Compare and Swap,比较和交换,需要借助CPU的cmpxchg指令完成
  • CAS指令需要三个操作数:内存位置V(变量的内存地址)、旧的期待值A、新值B
  • CAS指令执行时,当且仅当V符合旧的预期值A,处理器才用新值B更新V的值;否则,不执行更新
  • 不管是否更新V的值,都返回V的旧值,整个处理过程是一个原子操作

原子操作:在操作系统中又叫原子原语,是一个或者一组不可中断的指令操作

如何使用CAS?

有个类叫sun.misc.Unsafe,字面意思不安全,其实提供着CAS的主体实现,我们不能直接使用Unsafe,因其是final类,需要采用反射机制才能使用,不过JDK不希望我们直接使用,每个方法都加着@Deprecated注解。
实际使用过程中,我们可以直接使用它的包装类,诸如AtomicInteger、AtomicLong相关的atomic包下的原子类,这些都实现了Unsafe类中的compareAndSwapInt(),getAndAddInt()方法等等。
这货的底层不是很清楚,也许是给操作系统提供一些类CAS相关的指令去执行。

在多线程环境中,不建议使用线程不安全的操作或者类去共享数据,比如i++,全局Obj等,使用volatile修饰并不保证原子性,推荐真实项目中使用原子类对象,一是保证其操作原子性,二是对基础类型的包装,在方法修改数据时,可直接修改,而包装类和基础类型之间的性能对比,对于现在CPU来说,其实影响很微小。

CAS缺点

  1. ABA问题,是指一个变量的值是A,被一个线程改成了B,又很快的改成了A,在其他线程看来,值A是没有变的,其实已经变过了,这个问题在一些不是最终一致性的操作敏感业务里会 涉及到,可以通过加个版本号来解决。
  2. 循环时间过长,会对CPU性能受影响,一个线程的CAS操作长时间没有执行成功,所占用的资源会影响到CPU的处理能力。
  3. 只有对一个共享变量保持原子性,这个严格意义上说不是个大问题,是指多个原子类变量之间不保证原子性,不过我们可以使用AtomicReference传入一个对象,这这里面进行原子操作就可以解决。

3 无同步方案

这个好理解,同步是为了解决共享变量的数据一致性问题,那如果没同步就不存在线程安全隐患一说,一共有以下三种方案可选择:

3.1 可重入(Reentrant Code)

可重入代码又叫纯代码(Pure Code),对编码要求高,不仅要安全,而且要优雅。可在代码执行的任何时候中断他它,转去执行另外一段代码(包括递归调用它本身),控制权返回后,原来的程序不会出现任何错误

可重入代码的共同特征:

  • 不依赖存储在堆上的数据和公用的系统资源
  • 用到的状态量都由参数中传入
  • 不调用非可重用的方法

如何界定一段代码是否具有可重入性?

如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,这就是线程安全的代码。

3.2 栈封闭

这个好理解,大家知道局部变量是存放在栈空间的,如果多个线程访问使用一个方法的局部变量,是不会出现线程安全问题的,局部变量它逃不出一个声明它的方法而被其他方法访问到的,属于线程私有的一部分。

3.3 线程本地存储(Thread Local Storage)

如果一些变量是必须在不同业务逻辑中使用的,只要保证是在同一个线程内使用,那就可以实现TLS,因为对于线程维度来说,这些变量其实也是私有的,不会出现线程之间共享的问题,这就是ThreadLocal。

实际业务中ThreadLocal的使用示例:

public class ThreadContext {

    // 绑定线程的 ThreadLocal 对象
    private static final ThreadLocal<CreditCardParam> THREAD_CONTEXT = new ThreadLocal<>();

    // 构造方法私有化
    private ThreadContext(){}

    /**
     * 获取线程上下文的实例方法
     * @return
     */
    public static CreditCardParam getInstance(){
        CreditCardParam threadContext = THREAD_CONTEXT.get();
        if(null == threadContext){
            threadContext = CreditCardParam.builder().build();
            THREAD_CONTEXT.set(threadContext);
        }
        return threadContext;
    }

    /**
     * 线程上下文对象销毁
     */
    public static void destroy(){
        THREAD_CONTEXT.remove();
    }
}
ThreadLocal

在这里插入图片描述
以上是ThreadLocal所提供的部分方法

  • 每一个ThreadLocal包含一个ThreadLocalMap对象,该对象将ThreadLocal对象的hashCode值作为key,即ThreadLocal.threadLocalHashCode,将本地线程变量作为value,构成键值对
  • ThreadLocal对象是当前线程ThreadLocalMap对象的访问入口,通过threadLocal.set()为本地线程添加独享变量;通过threadLocal.get()获取本地线程独享变量的值
  • ThreadLocal、ThreadLocalMap、Thread的关系:Thread中可以绑定多个ThreadLocal对象,ThreadLocal对象中包含ThreadLocalMap对象,ThreadLocalMap对象中包含多个键值对,每个键值对的key是ThreadLocal对象的hashCode,value是本地线程变量

原型图:
在这里插入图片描述

ThreadLocal风险

先看段源码:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /**The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

上文提到,ThreadLocal中维护着一个ThreadLocalMap,而这个Map中的Entry对是个基于WeakReference的弱引用,那么问题来了,为什么要用弱引用?

设计成弱引用的目的是为了更好地对ThreadLocal进行回收,当我们在代码中将ThreadLocal的强引用置为null后,这时候Entry中的ThreadLocal理应被回收了,但是如果Entry的key被设置成强引用则该ThreadLocal就不能被回收

但是,如果在多线程环境下的线程池会有内存泄露的风险!

在threadLocal设为null和线程结束这段时间不会被回收,使用线程池的时候,线程结束是不会销毁的,或者线程不再使用,这个ThreadLocalMap就再也不能被访问到了,或者分配使用了又不再调用get,set方法,这期间就可能出现内存泄露!

说明:Java为了最小化减少内存泄露的可能性和影响,在ThreadLocal的get,set的时候都会清除线程Map里所有key为null的value

扩展:

内存泄露

被分配对象可达但无用

场景:

  • 创建和应用生命周期一样的单例对象
  • 创建匿名内部类的静态对象
  • 未关闭资源
  • 长时间存在的集合容器中创建生命周期短的对象

内存溢出

无法申请到足够的内存而产生的错误

场景:

  • 堆内存溢出
  • 方法区内存溢出(反射,静态变量)
  • 线程栈溢出(递归)

总结

综上,就是对线程安全的探究和梳理,在实际业务中,很多时候都会使用到多线程环境,不可避免就会用到线程池,线程中的数据共享和安全就很需要得到重视,甚至使用不当出现内存泄露问题,所以对任何一种可能涉及到的技术都要抱着一颗敬畏之心去学习和使用。

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

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

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值