Java多线程:线程同步(2)- ThreadLocal理解与分析

🥑一、ThreadLocal

🍆1.1 介绍

ThreadLocal线程变量,是用于线程内部存储变量,这些变量是每一个线程独享的,线程互相之间是访问不到变量的。线程变量与普通变量的区别在于,每一个线程都有ThreadLocal变量的副本,ThreadLocal变量通常是用private static修饰,当一个线程结束的时,它所使用的所有ThreadLocal变量的实例副本都可被回收。

ThreadLocal变量适用于线程需要独立实例,但是该实例需要在方法间共享。

🥔1.2 使用场景

从上述的介绍可以看出线程变量适用于线程需要独立变量并且变量需要在方法间共享。在实际的开发中主要有以下几个场景

  • 保存用户信息

    把用户信息保存在ThreadLocal中,线程在方法中通过ThreadLocal获取用户信息

  • 在不同的方法中传递信息,在方法1中有一个变量,在方法中需要使用,常规做法是通过方法传参,但是当方法调用层级太多之后,不需要使用变量的方法也要负责传参,如果通过ThreadLocal来实现,只需要在方法1中set进去,在方法3中get就行。

    void method1(){
      int a = 1;
      method2();
    }
    void method2(){
      method3();
    }
    void method3(){
      // 需要使用变量a
    }
    

🥕1.3 代码示例

下面写一个简单的示例,演示ThreadLocal如果在方法中传递值的。用两个线程模拟实际项目中用户的两次访问。用户第一次访问的时候在ThreadLocal设置了一个值,然后访问method1,然后再访问method2,在method2通过ThreadLocalget方法可以获取设置的值,并且两次访问的设置值和获取值是相互隔离的。

public class Demo {
    static ThreadLocal<String> local = new ThreadLocal<>();
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            local.set("用户第一次访问,这是我请求的参数");
            method1();
        }, "用户第一次访问的线程");
        t1.start();

        Thread t2 = new Thread(() -> {
            local.set("用户第二次访问,这是我请求的参数");
            method1();
        }, "用户第二次访问的线程");
        t2.start();
    }

    private static void method1() {
        method2();
    }

    private static void method2() {
        System.out.println(Thread.currentThread().getName() + ": " + local.get());
    }
}

🌽1.4 使用原理

要理解ThreadLocal使用原理,首先要了解使用ThreadLocal涉及到的几个类:ThreadThreadLocalThreadLocalMapEntry

Thread类里面有一个ThreadLocalMap的变量,而ThreadLocalMapThreadLocal的一个内部类,同时ThreadLocalMap有一个Entry数组类型的变量,这个Entry类是继承了弱引用。

在这里插入图片描述

ThreadLocalset其实是调用了ThreadLocalMapset,而ThreadLocalMapset是在Entry[]里面存放一个Entry对象,而Entry的构造方法有两个参数,一个key和一个valuekey就是ThreadLocal的引用,而value是我们要设置的值。
在这里插入图片描述

🌶1.5 源码解析

  • ThreadLocal的set方法

    ThreadLocalset就干了一件事情,调用当前线程的ThreadLocalMap变量的set方法,如果当前线程的ThreadLocalMap变量为空,就新建一个ThreadLocalMap变量。ThreadLocalMapset方法有两个参数,第一个是当前ThreadLocal对象,另外一个是要设置的value

    public void set(T value) {
      Thread t = Thread.currentThread();
      ThreadLocalMap map = getMap(t);
      if (map != null)
        map.set(this, value);
      else
        createMap(t, value);
    }
    
  • ThreadLocalMap的set方法

    ThreadLocalMapset主要是将传入进来的keyvalue 封装成一个Entry对象,然后把Entry对象放到ThreadLocalMap对象的一个Entry数组属性上,本质上就是往数组上添加元素。在数组中存放元素,首先要考虑的就是两个问题:如何保证散列和如何解决冲突,常见的散列算法有:除余散列、随机散列、斐波那契散列等。

    ThreadLocalMap中采用斐波那契保证了散列,具体过程如下:

    在添加新元素之前,首先的确定新元素在数组中的索引,在确定新元素的索引是下面这行代码,调用ThreadLocalthreadLocalHashCode变量,然后和数组长度减1做一个与运算,因为tab的长度len是2的n次方,len-1转换成二进制相当于高位是0,后面全是1,key.threadLocalHashCode len-1做与运算相当于key.threadLocalHashCode 只保留低n位。

    int i = key.threadLocalHashCode & (len-1);
    

    然后看下key.threadLocalHashCode 的原理并且是如何和斐波那契产生关联的,看下面的源码。

    调用threadLocalHashCode会调用一个nextHashCode方法,这个方法是一个AtomicInteger类型变量,初始值是0,调用一次就会加上这个常量HASH_INCREMENT,而这个常量是0x61c88647换算成十进制是1640531527。

    而黄金分割数(Math.sqrt(5) - 1)/2乘(1L << 32)也是1640531527,这样0x61c88647就和黄金分割数产生了关联,而黄金分割数能保证均匀。

    斐波那契数列的一些性质比如在 n 很大时, Fn+1/Fn≈ϕ ,其中 ϕ 是黄金分割数,等于 1.618

    private final int threadLocalHashCode = nextHashCode();
    
    private static AtomicInteger nextHashCode = new AtomicInteger();
    
    private static final int HASH_INCREMENT = 0x61c88647;
    
    private static int nextHashCode() {
      return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
    

    ThreadLocalMap中如何解决冲突,具体过程如下:

    在确定了新元素在tab上的索引的时候,这个位置有可能有元素,这样就产生了冲突,当冲突产生了之后ThreadLocalMap采用线性探测开放地址来解决冲突,冲突了之后调用nextIndex方法确定下一个索引位置。每次冲突了索引就增加1,当索引大于数组长度又从0开始。

    private static int nextIndex(int i, int len) {
      return ((i + 1 < len) ? i + 1 : 0);
    }
    

    k == key 当前key和当前索引的k相等的时候,相当于在线程内调用同一个ThreadLocalset方法两次,就用新的value替换以前的值即可。

    k = null,也就是数组的当前位置Entry对象不为空但是key是空的也就是key被回收了,说明当前索引位置可以用,用新的keyvalue调用replaceStaleEntry替换掉即可。

    在replaceStaleEntry有个比较有意思的设计,就是该方法判断当前节点已经出现了key=null的情况,那说明该数组其它位置也有这样的情况,会做一次删除脏Entry的操作。

    private void set(ThreadLocal<?> key, Object value) {
      Entry[] tab = table;
      int len = tab.length;
      int i = key.threadLocalHashCode & (len-1);
      for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        if (k == key) {
          e.value = value;
          return;
        }
        if (k == null) {
          replaceStaleEntry(key, value, i);
          return;
        }
      }
      tab[i] = new Entry(key, value);
      int sz = ++size;
      if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
    }
    

🍓二、原理解析

🍒2.1 内存划分

在这里插入图片描述

JVM会在堆空间创建一个Thread对象,同时也会在堆空间创建一个ThreadLocal对象,当进行set操作的时候,在堆空间创建一个Entry对象,Entry对象的key弱引用ThreadLocal对象

🍑2.2 内存泄漏

2.2.1 为什么会内存泄漏

从上面内存划分的图中可以得到三条引用路线:

①强引用:ThreadLocalRef -> ThreadLocal对象

②强引用:ThreadRef -> Thread对象 -> ThreadLocalMap对象 -> Entry对象 -> value对象

③弱引用:ThreadRef -> Thread对象 -> ThreadLocalMap对象 -> Entry对象 --> ThreadLocal对象

当引用路线①断开的时候,ThreadLocal对象只有一条弱引用路径了,一旦JVM执行了GC操作,那么ThreadLocal对象就被回收了,那么线路③最后key指向的ThreadLocalnull了。

但是线路①的引用一直存在,就会导致value没有被回收,导致这样的value越来越多就容易内存溢出了。

这里其实有个疑问,为什么线路①一直存在,按理说线程的生命周期结束这条线路也就不会存在了,但是现在一般都是用线程池,线程都是复用的,就会导致这样的情况越来越多。

2.2.2 如何解决内存泄漏

🥇ThreadLocal在设计的时候就考虑到这个问题,一般在set值的时候会主动去清理一次脏Entry,就是keynull,但是值还存在的数据。

🥈我们平时在使用的时候应该主动的去调用一次remove方法。

🥉一般我们在使用的时候将ThreadLocal变量定义成private static的,这样ThreadLocal不会轻易被回收,也就是Entry的key不会为空,这样我们就可以获取到value值,然后remove掉,能防止内存溢出。

🍐2.3 key为什么是弱引用

因为当ThreadLocal被回收了以后Entrykey就会为null,而ThreadLocalMapset方法会主动的清理一次这样的脏Entry,就算是用户忘了手动remove,这里也多了一次程序自动操作,这样能避免内存溢出。

如果ThreadLocal设计成强引用,就算是TreadLocalRef的引用断开了,也会因为Entrykey引用导致堆空间的ThreadLocal对象不能被回收,这样加大的内存溢出的概率。

2.4 ThreadLocal线程安全的吗

不一定,如果多个线程set的是同一个对象,其它线程去get的时候也是同一个对象引用,这样并不能保证线程安全。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
第3章 多线程(二) Java 高级程序设计 Java高级程序设计-多线程(二)全文共34页,当前为第1页。 回顾 进程一般代表一个应用程序,一个进程中可以包含多个线程。 合理使用多线程能够提高程序的执行效率,处理高并发应用。 线程的创建有继承Thread类和实现Runnable接口两种方式,通过Runnable方式可以更加容易实现多线程之间资源共享。 通过sleep可以使线程进入休眠状态,通过join方法可以让线程处于等待,其他线程执行完毕后继续执行。 线程生命周期包括:新建 就绪 运行 阻塞 死亡5种状态。 Java高级程序设计-多线程(二)全文共34页,当前为第2页。 本章内容 掌握同步代码块的使用 掌握同步方法的使用 理解线程死锁 掌握 ThreadLocal 类的使用 使用多线程模拟猴子采花 使用同步方法模拟购票 使用多线程模拟购物订单生成 使用 ThreadLocal 类模拟银行取款 Java高级程序设计-多线程(二)全文共34页,当前为第3页。 3.1 同步代码块 线程安全问题 同步代码块的使用 使用多线程模拟猴子采花 20 25 Java高级程序设计-多线程(二)全文共34页,当前为第4页。 3.1 线程安全 多线程编程时,由于系统对线程的调度具有一定的随机性,所以,使用多个线程操作同一个数据时,容易出现线程安全问题。 当多个线程访问同一个资源时,如果控制不好,也会造成数据的不正确性。 以银行取钱为例: 用户输入账户、密码,系统判断用户的账户、密码是否匹配 用户输入取款金额 系统判断账户余额是否大于取款金额 如果余额大于等于取款金额,则取款成功,否则取款失败 Java高级程序设计-多线程(二)全文共34页,当前为第5页。 3.1.1 模拟银行取款 使用多线程并发模拟两个账户并发取钱的问题: 创建账户类(Account),用于封装用户的账号和余额 public class Account { // 用户账号 private String no; // 账户中余额 private double balance; public Account() { } // 构造方法用于初始化账户、余额 public Account(String no, double balance) { this.no = no; this.balance = balance; } //getter和setter省略 Java高级程序设计-多线程(二)全文共34页,当前为第6页。 3.1.1 模拟银行取款 创建模拟两个线程的取款类 DrawThread,该类继承 Thread 类。取钱的业务逻辑为当余额不足时无法提取现金,当余额足够时系统吐出钞票,减少余额 public class DrawThread extends Thread { // 模拟用户账户 private Account account; // 当前线程索取钱数 private double drawAccount; //完成数据初始化工作 public DrawThread(String name, Account account, double drawAccount) { super(name); this.account = account; this.drawAccount = drawAccount; } public void run() { // 账户余额大于取钱数据 if (account.getBalance() >= drawAccount) { System.out.println(this.getName() + "\t 取款成功 ! 吐钞 :" + drawAccount); // 修改余额 account.setBalance(account.getBalance() - drawAccount); System.out.println("\t 余额 : " + account.getBalance()); } else { System.out.println(this.getName() + " 取钱失败!余额不足 "); } } } // 当多个线程同时修改同一个共享数据时,将涉及数据安全问题 Java高级程序设计-多线程(二)全文共34页,当前为第7页。 3.1.1 模拟银行取款 由于多线程并发问题,一个线程执行余额操作可能未完毕,另外一个线程读取或者也在操作余额,必然会引起数据的不准确性。 这个时候需要在线程中加入对数据的保护机制,从而达到防止并发引起的数据不准确。 Java高级程序设计-多线程(二)全文共34页,当前为第8页。 3.1.2 同步代码块的使用 Java多线程中引入了同步监视器,使用同步监视器的常用方式是使用同步代码块,保

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值