关于java:ThreadLocal不好用那是你没用对

9次阅读

共计 14151 个字符,预计需要花费 36 分钟才能阅读完成。

在 Java 中,如果要问哪个类应用简略,但用好最不简略?我想你的脑海中肯定会浮现出一次词——“ThreadLocal”。

的确如此,ThreadLocal 本来设计是为了解决并发时,线程共享变量的问题,但因为适度设计,如弱援用和哈希碰撞,从而导致它的了解难度大和应用老本低等问题。当然,如果稍有不慎还是导致脏数据、内存溢出、共享变量更新等问题,但即便如此,ThreadLocal 仍旧有适宜本人的应用场景,以及无可取代的价值,比方本文要介绍了这两种应用场景,除了 ThreadLocal 之外,还真没有适合的代替计划。

应用场景 1:本地变量

咱们以多线程格式化工夫为例,来演示 ThreadLocal 的价值和作用,当咱们在多个线程中格式化工夫时,通常会这样操作。

① 2 个线程格式化

当有 2 个线程进行工夫格式化时,咱们能够这样写:

import java.text.SimpleDateFormat;
import java.util.Date;

public class Test {public static void main(String[] args) throws InterruptedException {
        // 创立并启动线程 1
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 失去工夫对象
                Date date = new Date(1 * 1000);
                // 执行工夫格式化
                formatAndPrint(date);
            }
        });
        t1.start();
        // 创立并启动线程 2
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                // 失去工夫对象
                Date date = new Date(2 * 1000);
                // 执行工夫格式化
                formatAndPrint(date);
            }
        });
        t2.start();}

    /**
     * 格式化并打印后果
     * @param date 工夫对象
     */
    private static void formatAndPrint(Date date) {
        // 格式化工夫对象
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
        // 执行格式化
        String result = simpleDateFormat.format(date);
        // 打印最终后果
        System.out.println("工夫:" + result);
    }
}

以上程序的执行后果为:

下面的代码因为创立的线程数量并不多,所以咱们能够给每个线程创立一个公有对象 SimpleDateFormat 来进行工夫格式化。

② 10 个线程格式化

当线程的数量从 2 个降级为 10 个时,咱们能够应用 for 循环来创立多个线程执行工夫格式化,具体实现代码如下:

import java.text.SimpleDateFormat;
import java.util.Date;

public class Test {public static void main(String[] args) throws InterruptedException {for (int i = 0; i < 10; i++) {
            int finalI = i;
            // 创立线程
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    // 失去工夫对象
                    Date date = new Date(finalI * 1000);
                    // 执行工夫格式化
                    formatAndPrint(date);
                }
            });
            // 启动线程
            thread.start();}
    }
    /**
     * 格式化并打印工夫
     * @param date 工夫对象
     */
    private static void formatAndPrint(Date date) {
        // 格式化工夫对象
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
        // 执行格式化
        String result = simpleDateFormat.format(date);
        // 打印最终后果
        System.out.println("工夫:" + result);
    }
}

以上程序的执行后果为:

从上述后果能够看出,尽管此时创立的线程数和 SimpleDateFormat 的数量不算少,但程序还是能够失常运行的。

③ 1000 个线程格式化

然而当咱们将线程的数量从 10 个变成 1000 个的时候,咱们就不能单纯的应用 for 循环来创立 1000 个线程的形式来解决问题了,因为这样频繁的新建和销毁线程会造成大量的零碎开销和线程适度争抢 CPU 资源的问题。

所以通过一番思考后,咱们决定应用线程池来执行这 1000 次的工作,因为线程池能够复用线程资源,无需频繁的新建和销毁线程,也能够通过控制线程池中线程的数量来防止过多线程所导致的 **CPU** 资源适度争抢和线程频繁切换所造成的性能问题,而且咱们能够将 SimpleDateFormat 晋升为全局变量,从而防止每次执行都要新建 SimpleDateFormat 的问题,于是咱们写下了这样的代码:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class App {
    // 工夫格式化对象
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");

    public static void main(String[] args) throws InterruptedException {
        // 创立线程池执行工作
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(10, 10, 60,
                TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            // 执行工作
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 失去工夫对象
                    Date date = new Date(finalI * 1000);
                    // 执行工夫格式化
                    formatAndPrint(date);
                }
            });
        }
        // 线程池执行完工作之后敞开
        threadPool.shutdown();}

    /**
     * 格式化并打印工夫
     * @param date 工夫对象
     */
    private static void formatAndPrint(Date date) {
        // 执行格式化
        String result = simpleDateFormat.format(date);
        // 打印最终后果
        System.out.println("工夫:" + result);
    }
}

以上程序的执行后果为:

当咱们怀着无比喜悦的情绪去运行程序的时候,却发现意外产生了,这样写代码居然会呈现线程平安的问题。从上述后果能够看出,程序的打印后果居然有反复内容的,正确的状况应该是没有反复的工夫才对。

PS:所谓的 线程平安问题是指:在多线程的执行中,程序的执行后果与预期后果不相符的状况

a) 线程平安问题剖析

为了找到问题所在,咱们尝试查看 SimpleDateFormatformat 办法的源码来排查一下问题,format 源码如下:

private StringBuffer format(Date date, StringBuffer toAppendTo,
                                FieldDelegate delegate) {
    // 留神此行代码
    calendar.setTime(date);

    boolean useDateFormatSymbols = useDateFormatSymbols();

    for (int i = 0; i < compiledPattern.length;) {int tag = compiledPattern[i] >>> 8;
        int count = compiledPattern[i++] & 0xff;
        if (count == 255) {count = compiledPattern[i++] << 16;
            count |= compiledPattern[i++];
        }

        switch (tag) {
            case TAG_QUOTE_ASCII_CHAR:
                toAppendTo.append((char)count);
                break;

            case TAG_QUOTE_CHARS:
                toAppendTo.append(compiledPattern, i, count);
                i += count;
                break;

            default:
                subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
                break;
        }
    }
    return toAppendTo;
}

从上述源码能够看出,在执行 SimpleDateFormat.format 办法时,会应用 calendar.setTime 办法将输出的工夫进行转换,那么咱们设想一下这样的场景:

  1. 线程 1 执行了 calendar.setTime(date) 办法,将用户输出的工夫转换成了前面格式化时所须要的工夫;
  2. 线程 1 暂停执行,线程 2 失去 CPU 工夫片开始执行;
  3. 线程 2 执行了 calendar.setTime(date) 办法,对工夫进行了批改;
  4. 线程 2 暂停执行,线程 1 得出 CPU 工夫片继续执行,因为线程 1 和线程 2 应用的是同一对象,而工夫曾经被线程 2 批改了,所以此时当线程 1 继续执行的时候就会呈现线程平安的问题了。

失常的状况下,程序的执行是这样的:

非线程平安的执行流程是这样的:

b) 解决线程平安问题:加锁

当呈现线程平安问题时,咱们想到的第一解决方案就是加锁,具体的实现代码如下:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class App {
    // 工夫格式化对象
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");

    public static void main(String[] args) throws InterruptedException {
        // 创立线程池执行工作
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(10, 10, 60,
                TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            // 执行工作
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 失去工夫对象
                    Date date = new Date(finalI * 1000);
                    // 执行工夫格式化
                    formatAndPrint(date);
                }
            });
        }
        // 线程池执行完工作之后敞开
        threadPool.shutdown();}

    /**
     * 格式化并打印工夫
     * @param date 工夫对象
     */
    private static void formatAndPrint(Date date) {
        // 执行格式化
        String result = null;
        // 加锁
        synchronized (App.class) {result = simpleDateFormat.format(date);
        }
        // 打印最终后果
        System.out.println("工夫:" + result);
    }
}

以上程序的执行后果为:

从上述后果能够看出,应用了 synchronized 加锁之后程序就能够失常的执行了。

加锁的毛病

加锁的形式尽管能够解决线程平安的问题,但同时也带来了新的问题,当程序加锁之后,所有的线程必须排队执行某些业务才行,这样无形中就升高了程序的运行效率了

有没有既能解决线程平安问题,又能进步程序的执行速度的解决方案呢?

答案是:有的,这个时候 ThreadLocal 就要上场了。

c) 解决线程平安问题:ThreadLocal

1.ThreadLocal 介绍

ThreadLocal 从字面的意思来了解是线程本地变量的意思,也就是说它是线程中的公有变量,每个线程只能应用本人的变量。

以下面线程池格式化工夫为例,当线程池中有 10 个线程时,SimpleDateFormat 会存入 ThreadLocal 中,它也只会创立 10 个对象,即便要执行 1000 次工夫格式化工作,仍然只会新建 10 个 SimpleDateFormat 对象,每个线程调用本人的 ThreadLocal 变量。

2.ThreadLocal 根底应用

ThreadLocal 罕用的外围办法有三个:

  1. set 办法:用于设置线程独立变量正本。 没有 set 操作的 ThreadLocal 容易引起脏数据。
  2. get 办法:用于获取线程独立变量正本。 没有 get 操作的 ThreadLocal 对象没有意义。
  3. remove 办法:用于移除线程独立变量正本。 没有 remove 操作容易引起内存透露。

ThreadLocal 所有办法如下图所示:

官网阐明文档:https://docs.oracle.com/javase/8/docs/api/

ThreadLocal 根底用法如下:

/**
 * @公众号:Java 中文社群
 */
public class ThreadLocalExample {
    // 创立一个 ThreadLocal 对象
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        // 线程执行工作
        Runnable runnable = new Runnable() {
            @Override
            public void run() {String threadName = Thread.currentThread().getName();
                System.out.println(threadName + "存入值:" + threadName);
                // 在 ThreadLocal 中设置值
                threadLocal.set(threadName);
                // 执行办法,打印线程中设置的值
                print(threadName);
            }
        };
        // 创立并启动线程 1
        new Thread(runnable, "MyThread-1").start();
        // 创立并启动线程 2
        new Thread(runnable, "MyThread-2").start();}

    /**
     * 打印线程中的 ThreadLocal 值
     * @param threadName 线程名称
     */
    private static void print(String threadName) {
        try {
            // 失去 ThreadLocal 中的值
            String result = threadLocal.get();
            // 打印后果
            System.out.println(threadName + "取出值:" + result);
        } finally {
            // 移除 ThreadLocal 中的值(避免内存溢出)threadLocal.remove();}
    }
}

以上程序的执行后果为:

从上述后果能够看出,每个线程只会读取到属于本人的 ThreadLocal 值。

3.ThreadLocal 高级用法

① 初始化:initialValue
public class ThreadLocalByInitExample {
    // 定义 ThreadLocal
    private static ThreadLocal<String> threadLocal = new ThreadLocal(){
        @Override
        protected String initialValue() {System.out.println("执行 initialValue() 办法");
            return "默认值";
        }
    };

    public static void main(String[] args) {
        // 线程执行工作
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                // 执行办法,打印线程中数据(未设置值打印)print(threadName);
            }
        };
        // 创立并启动线程 1
        new Thread(runnable, "MyThread-1").start();
        // 创立并启动线程 2
        new Thread(runnable, "MyThread-2").start();}

    /**
     * 打印线程中的 ThreadLocal 值
     * @param threadName 线程名称
     */
    private static void print(String threadName) {
        // 失去 ThreadLocal 中的值
        String result = threadLocal.get();
        // 打印后果
        System.out.println(threadName + "失去值:" + result);
    }
}

以上程序的执行后果为:

当应用了 #threadLocal.set 办法之后,initialValue 办法就不会被执行了,如下代码所示:

public class ThreadLocalByInitExample {
    // 定义 ThreadLocal
    private static ThreadLocal<String> threadLocal = new ThreadLocal() {
        @Override
        protected String initialValue() {System.out.println("执行 initialValue() 办法");
            return "默认值";
        }
    };

    public static void main(String[] args) {
        // 线程执行工作
        Runnable runnable = new Runnable() {
            @Override
            public void run() {String threadName = Thread.currentThread().getName();
                System.out.println(threadName + "存入值:" + threadName);
                // 在 ThreadLocal 中设置值
                threadLocal.set(threadName);
                // 执行办法,打印线程中设置的值
                print(threadName);
            }
        };
        // 创立并启动线程 1
        new Thread(runnable, "MyThread-1").start();
        // 创立并启动线程 2
        new Thread(runnable, "MyThread-2").start();}

    /**
     * 打印线程中的 ThreadLocal 值
     * @param threadName 线程名称
     */
    private static void print(String threadName) {
        try {
            // 失去 ThreadLocal 中的值
            String result = threadLocal.get();
            // 打印后果
            System.out.println(threadName + "取出值:" + result);
        } finally {
            // 移除 ThreadLocal 中的值(避免内存溢出)threadLocal.remove();}
    }
}

以上程序的执行后果为:

为什么 set 办法之后,初始化代码就不执行了?

要了解这个问题,须要从 ThreadLocal.get() 办法的源码中失去答案,因为初始化办法 initialValueThreadLocal 创立时并不会立刻执行,而是在调用了 get 办法只会才会执行,测试代码如下:

import java.util.Date;

public class ThreadLocalByInitExample {
    // 定义 ThreadLocal
    private static ThreadLocal<String> threadLocal = new ThreadLocal() {
        @Override
        protected String initialValue() {System.out.println("执行 initialValue() 办法" + new Date());
            return "默认值";
        }
    };
    public static void main(String[] args) {
        // 线程执行工作
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                // 失去以后线程名称
                String threadName = Thread.currentThread().getName();
                // 执行办法,打印线程中设置的值
                print(threadName);
            }
        };
        // 创立并启动线程 1
        new Thread(runnable, "MyThread-1").start();
        // 创立并启动线程 2
        new Thread(runnable, "MyThread-2").start();}

    /**
     * 打印线程中的 ThreadLocal 值
     * @param threadName 线程名称
     */
    private static void print(String threadName) {System.out.println("进入 print() 办法" + new Date());
        try {
            // 休眠 1s
            Thread.sleep(1000);
        } catch (InterruptedException e) {e.printStackTrace();
        }
        // 失去 ThreadLocal 中的值
        String result = threadLocal.get();
        // 打印后果
        System.out.println(String.format("%s 获得值:%s %s",
                threadName, result, new Date()));
    }
}

以上程序的执行后果为:

从上述打印的工夫能够看出:initialValue 办法并不是在 ThreadLocal 创立时执行的,而是在调用 Thread.get 办法时才执行的。

接下来来看 Threadlocal.get 源码的实现:

public T get() {
    // 失去以后的线程
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    // 判断 ThreadLocal 中是否有数据
    if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {@SuppressWarnings("unchecked")
            T result = (T)e.value;
            // 有 set 值,间接返回数据
            return result;
        }
    }
    // 执行初始化办法【重点关注】return 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;
}

从上述源码能够看出,当 ThreadLocal 中有值时会间接返回值 e.value,只有 Threadlocal 中没有任何值时才会执行初始化办法 initialValue

注意事项—类型必须保持一致

留神在应用 initialValue 时,返回值的类型要和 ThreadLoca 定义的数据类型保持一致,如下图所示:

如果数据不统一就会造成 ClassCaseException 类型转换异样,如下图所示:

② 初始化 2:withInitial
import java.util.function.Supplier;

public class ThreadLocalByInitExample {
    // 定义 ThreadLocal
    private static ThreadLocal<String> threadLocal =
            ThreadLocal.withInitial(new Supplier<String>() {
                @Override
                public String get() {System.out.println("执行 withInitial() 办法");
                    return "默认值";
                }
            });
    public static void main(String[] args) {
        // 线程执行工作
        Runnable runnable = new Runnable() {
            @Override
            public void run() {String threadName = Thread.currentThread().getName();
                // 执行办法,打印线程中设置的值
                print(threadName);
            }
        };
        // 创立并启动线程 1
        new Thread(runnable, "MyThread-1").start();
        // 创立并启动线程 2
        new Thread(runnable, "MyThread-2").start();}

    /**
     * 打印线程中的 ThreadLocal 值
     * @param threadName 线程名称
     */
    private static void print(String threadName) {
        // 失去 ThreadLocal 中的值
        String result = threadLocal.get();
        // 打印后果
        System.out.println(threadName + "失去值:" + result);
    }
}

以上程序的执行后果为:

通过上述的代码发现,withInitial 办法的应用好和 initialValue 如同没啥区别,那为啥还要造出两个相似的办法呢?客官莫焦急,持续往下看。

③ 更简洁的 withInitial 应用

withInitial 办法的劣势在于能够更简略的实现变量初始化,如下代码所示:

public class ThreadLocalByInitExample {
    // 定义 ThreadLocal
    private static ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> "默认值");
    public static void main(String[] args) {
        // 线程执行工作
        Runnable runnable = new Runnable() {
            @Override
            public void run() {String threadName = Thread.currentThread().getName();
                // 执行办法,打印线程中设置的值
                print(threadName);
            }
        };
        // 创立并启动线程 1
        new Thread(runnable, "MyThread-1").start();
        // 创立并启动线程 2
        new Thread(runnable, "MyThread-2").start();}

    /**
     * 打印线程中的 ThreadLocal 值
     * @param threadName 线程名称
     */
    private static void print(String threadName) {
        // 失去 ThreadLocal 中的值
        String result = threadLocal.get();
        // 打印后果
        System.out.println(threadName + "失去值:" + result);
    }
}

以上程序的执行后果为:

4.ThreadLocal 版工夫格式化

理解了 ThreadLocal 的应用之后,咱们回到本文的主题,接下来咱们将应用 ThreadLocal 来实现 1000 个工夫的格式化,具体实现代码如下:

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class MyThreadLocalByDateFormat {
    // 创立 ThreadLocal 并设置默认值
    private static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal =
            ThreadLocal.withInitial(() -> new SimpleDateFormat("mm:ss"));

    public static void main(String[] args) {
        // 创立线程池执行工作
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(10, 10, 60,
                TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000));
        // 执行工作
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            // 执行工作
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    // 失去工夫对象
                    Date date = new Date(finalI * 1000);
                    // 执行工夫格式化
                    formatAndPrint(date);
                }
            });
        }
        // 线程池执行完工作之后敞开
        threadPool.shutdown();
        // 线程池执行完工作之后敞开
        threadPool.shutdown();}
    /**
     * 格式化并打印工夫
     * @param date 工夫对象
     */
    private static void formatAndPrint(Date date) {
        // 执行格式化
        String result = dateFormatThreadLocal.get().format(date);
        // 打印最终后果
        System.out.println("工夫:" + result);
    }
}

以上程序的执行后果为:

从上述后果能够看出,应用 ThreadLocal 也能够解决线程并发问题,并且防止了代码加锁排队执行的问题。

应用场景 2:跨类传递数据

除了下面的应用场景之外,咱们还能够 应用 **ThreadLocal** 来实现线程中跨类、跨办法的数据传递。比方登录用户的 User 对象信息,咱们须要在不同的子系统中屡次应用,如果应用传统的形式,咱们须要应用办法传参和返回值的形式来传递 User 对象,然而这样就无形中造成了类和类之间,甚至是零碎和零碎之间的互相耦合了,所以此时咱们能够应用 ThreadLocal 来实现 User 对象的传递。

确定了计划之后,接下来咱们来实现具体的业务代码。咱们能够先在主线程中结构并初始化一个 User 对象,并将此 User 对象存储在 ThreadLocal 中,存储实现之后,咱们就能够在同一个线程的其余类中,如仓储类或订单类中间接获取并应用 User 对象了,具体实现代码如下。

主线程中的业务代码:

public class ThreadLocalByUser {public static void main(String[] args) {
        // 初始化用户信息
        User user = new User("Java");
        // 将 User 对象存储在 ThreadLocal 中
        UserStorage.setUser(user);
        // 调用订单零碎
        OrderSystem orderSystem = new OrderSystem();
        // 增加订单(办法内获取用户信息)orderSystem.add();
        // 调用仓储零碎
        RepertorySystem repertory = new RepertorySystem();
        // 减库存(办法内获取用户信息)repertory.decrement();}
}

User 实体类:

/**
 * 用户实体类
 */
class User {public User(String name) {this.name = name;}
    private String name;
    public String getName() {return name;}
    public void setName(String name) {this.name = name;}
}

ThreadLocal 操作类:

/**
 * 用户信息存储类
 */
class UserStorage {
    // 用户信息
    public static ThreadLocal<User> USER = new ThreadLocal();

    /**
     * 存储用户信息
     * @param user 用户数据
     */
    public static void setUser(User user) {USER.set(user);
    }
}

订单类:

/**
 * 订单类
 */
class OrderSystem {
    /**
     * 订单增加办法
     */
    public void add() {
        // 失去用户信息
        User user = UserStorage.USER.get();
        // 业务解决代码(疏忽)...
        System.out.println(String.format("订单零碎收到用户:%s 的申请。",
                user.getName()));
    }
}

仓储类:

/**
 * 仓储类
 */
class RepertorySystem {
    /**
     * 减库存办法
     */
    public void decrement() {
        // 失去用户信息
        User user = UserStorage.USER.get();
        // 业务解决代码(疏忽)...
        System.out.println(String.format("仓储零碎收到用户:%s 的申请。",
                user.getName()));
    }
}

以上程序的最终执行后果:

从上述后果能够看出,当咱们在主线程中先初始化了 User 对象之后,订单类和仓储类无需进行任何的参数传递也能够失常取得 User 对象了,从而 实现了一个线程中,跨类和跨办法的数据传递

总结

应用 ThreadLocal 能够创立线程公有变量,所以不会导致线程平安问题,同时应用 ThreadLocal 还能够防止因为引入锁而造成线程排队执行所带来的性能耗费;再者应用 ThreadLocal 还能够实现一个线程内跨类、跨办法的数据传递。

参考 & 鸣谢

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

《Java 并发编程 78 讲》

关注公号「Java 中文社群」查看更多有意思、涨常识的并发编程文章。

正文完
 0