【并发编程】(九)线程安全的代码及ThreadLocal的使用

1.线程安全的代码

在前面的博客中提到了Java并发编程中需要注意的线程安全问题,在不同的场景选择使用synchronized和volatile来保证线程之间的原子性、可见性、有序性。那么有没有一种代码,完全不需要做任何的同步操作也一定是线程安全的呢?
答案是肯定有,常见的有下面两种情况:

  • 对多个线程访问的共享变量只读不写。
  • 使用未逃逸的局部变量。

第一点比较好理解,只要不对共享变量做修改,那所有线程的工作内存中保存的副本就一直不会变,线程就是安全的。
对于第二点,局部变量是在方法体运行时定义的,运行方法时会在虚拟机栈中压入一个栈帧,而栈是属于线程私有的。也就是说,只要局部变量未逃逸,则方法中定义的局部变量就是线程私有的,那肯定就是线程安全的。

那么什么叫逃逸呢?

JVM的逃逸分析对逃逸定义了两种形式,分别是:

  • 方法逃逸:作为方法的返回值或作为参数传递给其它方法。
  • 线程逃逸:将局部变量赋值给类变量,让其他线程可以访问到。

方法逃逸不会有什么影响,因为都属于是在同一个线程的栈中不断的压入栈帧,其他线程无法访问到。而线程逃逸可能会导致线程安全问题,所以不做同步操作又要保证线程安全的话,就不要将局部变量赋值给类变量


注:编译器的锁消除优化的判断也是通过逃逸分析来做的,既然变量不会被其它线程访问到,那加上synchronized这样的同步操作只是在浪费性能而已,此时就会判断出可以对消除这个同步锁。

1.1.线程逃逸的例子

验证上述的线程逃逸后,如果不加同步操作,运行的线程将不再安全。

public class ThreadEscapeDemo {

    private List<Integer> list = new ArrayList<>();

    public void test1() {
        List<Integer> localList = new ArrayList<>();
        localList.add(1);
        this.list = localList;

        try {
            // 睡1秒,让其他线程对类变量进行操作
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        for (Integer integer : localList) {
            // 如果线程安全,只会打印出1
            System.out.print(integer);
        }
    }

    public void test2() {
        this.list.add(2);
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadEscapeDemo escapeDemo = new ThreadEscapeDemo();

        Thread t1 = new Thread(escapeDemo::test1);
        Thread t2 = new Thread(escapeDemo::test2);

        t1.start();
        // 睡10毫秒,让t1先于t2运行
        Thread.sleep(10L);
        t2.start();
    }
    
}

打印结果为12,但期望打印出的结果为1。

2.ThreadLocal

2.1.概念

ThreadLocal是一个维护线程本地变量的工具类,每个线程都可以通过ThreadLocal来存放自己的本地变量,使自己的本地变量与其它线程隔离,达到线程私有化的效果。

2.2.如何使用ThreadLocal

ThreadLocal的使用非常的简单,从使用效果上来说,一个ThreadLocal只能存储一个值,只需要记录3个关键方法就可以了,分别是set()get()remove()

2.2.1.实现线程隔离

下面是一个ThreadLocal做线程隔离的例子:

public class ThreadLocalDemo {

    // 使用ThreadLocal的初始化方法,初始化值为0
    ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

    public void test() {
        for (int i = 0; i < 10; i++) {
            Integer value = threadLocal.get();
            threadLocal.set(++value);
        }
        System.out.println(Thread.currentThread().getName() + ":" + threadLocal.get());
        // 在使用完ThreadLocal后要remove掉,避免内存泄露
        threadLocal.remove();
    }

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> new ThreadLocalDemo().test()).start();
        }
    }
}

打印结果:

Thread-3:10
Thread-1:10
Thread-0:10
Thread-2:10
Thread-4:10

每个线程打印出的结果都是10,而不是50或者其他的什么数字,说明ThreadLocal为每个线程都分配了线程私有的本地变量,各个线程中的变量实现了隔离。

2.3.实现参数传递

我们日常使用的方法间参数传递的方式有两种,要么是通过方法定义的形参来传递,要么是使用公用的成员变量传递,ThreadLocal的参数传递方式和成员变量的方式类似。
例子如下:

public class ThreadLocalArgsTrans {

    ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public void test1() {
        threadLocal.set("Hello Thread Local");
    }

    public void test2() {
        System.out.println(threadLocal.get());
    }

    public static void main(String[] args) {
        ThreadLocalArgsTrans t = new ThreadLocalArgsTrans();
        t.test1();
        t.test2();
    }

}

调用test2()后打印出

Hello Thread Local

2.4.PageHelper是如何使用ThreadLocal的

2.4.1.PageHelper的引入和使用

看了两个简单的示例后,可以来看一看PageHelper是怎么通过ThreadLocal来实现线程隔离和参数传递的。
首先需要引入mybatispageHelper的maven依赖,然后写一个分页代码:

public List<User> queryUser() {
    Page<User> users = PageHelper.startPage(1, 2);
    userMapper.selectAll();
    return users;
}

其中selectAll()里面就写了一个简单的查询所有记录的sql:

select * from `user`;

我们把mybatis的sql日志打开,看一下执行这个方法会打印出什么日志。
在这里插入图片描述
可以看出PageHelper对打印的sql做出了修改,从代码中的蛛丝马迹去看,只是多加入了PageHelper.startPage()这行代码,点进去然后在一顿重载方法后找到执行逻辑的方法。

2.4.2.ThreadLocal存放分页信息

public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
    Page<E> page = new Page<E>(pageNum, pageSize, count);
    
    // 这里省略了部分代码……
    
    setLocalPage(page);
    return page;
}

上面的方法中新建了一个Page对象,并调用了SetLocalPage(page)的方法,看这个名字可能就和ThreadLocal有关了,点进去之后可以看到挨着的三个和ThreadLocal有关的方法:

public abstract class PageMethod {
    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

    /**
     * 设置 Page 参数
     */
    protected static void setLocalPage(Page page) {
        LOCAL_PAGE.set(page);
    }

    /**
     * 获取 Page 参数
     */
    public static <T> Page<T> getLocalPage() {
        return LOCAL_PAGE.get();
    }

    /**
     * 移除本地变量
     */
    public static void clearPage() {
        LOCAL_PAGE.remove();
    }
    // 后面的代码省略……
}

setLocalPage()方法将page对象放到了到了ThreadLocal中。那page对象是什么时候使用的呢?接着往下看。

2.4.3.PageHelper注册拦截器

我们使用的PageHelper是通过mybatisInterceptor接口来实现了一个自定义插件,我们可以定义一个实现类实现这个拦截器,然后将这个拦截器放入到mybatis的拦截器链中,mybaits框架就会在执行sql之前去回调intercept()方法,下图就是拦截器的源码。

package org.apache.ibatis.plugin;

import java.util.Properties;

/**
 * @author Clinton Begin
 */
public interface Interceptor {

    Object intercept(Invocation invocation) throws Throwable;

    Object plugin(Object target);

    void setProperties(Properties properties);

}

如果使用的是pageHelper的springboot starter,我们就不需要再去自定义拦截器了,在starter的jar包中,已经定义了一个javaconfig类PageHelperAutoConfiguration,对PageHelper注册mybatis的拦截器实现了自动装配:

@PostConstruct
public void addPageInterceptor() {
    PageInterceptor interceptor = new PageInterceptor();
    
    // 这里省略了部分配置代码……
    
    for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
    	// 将pageHelper的拦截器注册到mybaits的拦截器链中
        sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
    }
}

2.4.4.通过ThreadLocal获取分页对象

接下来只需要关心PageInterceptor中的intercept()方法,分页一定是使用了这个方法,而这个方法中一定会使用到ThreadLocal,下面的代码中省略了部分和ThreadLocal无关的代码。

public Object intercept(Invocation invocation) throws Throwable {
	try {
	    // 省略……
	
	    //调用方法判断是否需要进行分页,如果不需要,直接返回结果
	    if (!dialect.skip(ms, parameter, rowBounds)) {
	        // 省略……
	        //判断是否需要进行 count 查询
	        if (dialect.beforeCount(ms, parameter, rowBounds)) {
	            // 省略……
	            // 返回 true 时继续分页查询,false 时直接返回
	            if (!dialect.afterCount(count, parameter, rowBounds)) {
	                // 省略……
	            }
	        }
	
	        //判断是否需要进行分页查询
	        if (dialect.beforePage(ms, parameter, rowBounds)) {
	            // 省略……
	        }
	    }
	    // 装配分页返回对象
	    return dialect.afterPage(resultList, parameter, rowBounds);
	} finally {
	    // 使用完后,remove掉ThreadLocal中的分页参数
	    dialect.afterAll();
	}
}

上面的几个方法,每一个都需要用到Page对象来完成判断,但是在方法中没有任何一个位置传入了这个参数,那page对象这个参数来自哪里,想必大家心里已经有了答案了。

上面的每个方法获取Page对象,都是通过同一个方法来获取的,PageMethod类中的getLocalPage()方法,从ThreadLocal中获取分页参数。

需要注意的是,在方法调用结束后,需要清空ThreadLocal中的数据,除了避免内存泄露以外,还有个原因是线程池中的线程的可以复用的,如果不清空,其它的连接使用了这个线程,本来不会去做分页查询,也会因为ThreadLocal中有page对象而去执行分页查询。
上面的afterAll()就是在执行这个清理的过程,最终调用到clearPage()方法。

3.总结

线程安全的代码:

  • 每个线程执行的方法中,只会对自身的线程本地变量或局部变量做读写操作的代码。
  • 如果有对共享变量的操作,需要设置这个共享变量不可变(final),或者不要对共享变量做写操作。

ThreadLocal的使用方法:

  • 一般是在各个线程公用的类中(如工具类),定义ThreadLocal成员变量,线程需要使用到这个ThreadLocal的时候,直接去调用get()和set()方法来进行方法间的参数传递。
  • 由于线程可能是可复用的,需要是使用完成后调用remove()方法,清理下已经不再使用的线程本地变量。

ThreadLocal的实现原理可以阅读下一篇博客《线程本地变量的实现——ThreadLocal原理详解》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

挥之以墨

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

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

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

打赏作者

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

抵扣说明:

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

余额充值