Java多线程变量共享与隔离

文章目录

  • 线程相关
    • 线程的相关API
    • 线程的调度
    • 线程的优先级
  • 方法和变量的线程安全问题
    • 静态方法
    • 非静态方法
    • 静态变量
    • 实例变量
    • 局部变量
  • 变量共享
    • 共享变量线程安全问题
      • 可见性
      • 可见性举例
    • 共享变量可见性的实现
      • synchronized
      • volatile
      • synchronized和volatile比较
      • volatile适用情况
      • 特殊操作会从主内存中拉取值
  • 变量隔离
    • ThreadLocal
      • 使用ThreadLocal的好处
      • ThreadLocal主要方法
      • ThreadLocal源码分析
      • ThreadLocal注意事项
      • 注意事项


线程相关

线程的相关API

  1. Thread.currentThread().getName():获取当前线程的名字
  2. start():1.启动当前线程2.调用线程中的run方法
  3. run():通常需要重写Thread类中的此方法,将创建的线程要执行的操作声明在此方法中
  4. currentThread():静态方法,返回执行当前代码的线程
  5. getName():获取当前线程的名字
  6. setName():设置当前线程的名字
  7. yield():主动释放当前线程的执行权
  8. join():在线程中插入执行另一个线程,该线程被阻塞,直到插入执行的线程完全执行完毕以后,该线程才继续执行下去
  9. stop():过时方法。当执行此方法时,强制结束当前线程。
  10. sleep(long millitime):线程休眠一段时间
  11. isAlive():判断当前线程是否存活

线程的调度

调度策略:

  1. 时间片:线程的调度采用时间片轮转的方式
  2. 抢占式:高优先级的线程抢占CPU

Java的调度方法:

  1. 对于同优先级的线程组成先进先出队列(先到先服务),使用时间片策略
  2. 对高优先级,使用优先调度的抢占式策略

线程的优先级

等级:
MAX_PRIORITY:10
MIN_PRIORITY:1
NORM_PRIORITY:5

方法:
getPriority():返回线程优先级
setPriority(int newPriority):改变线程的优先级

注意事项:高优先级的线程要抢占低优先级的线程的cpu的执行权。但是仅是从概率上来说的,高优先级的线程更有可能被执行。并不意味着只有高优先级的线程执行完以后,低优先级的线程才执行。

方法和变量的线程安全问题

静态方法

与静态成员变量一样,属于类本身,在类装载的时候被装载到内存(Memory),不自动进行销毁,会一直存在于内存中,直到JVM关闭。

非静态方法

又叫实例化方法,属于实例对象,实例化后才会分配内存,必须通过类的实例来引用。不会常驻内存,当实例对象被JVM 回收之后,也跟着消失。

静态变量

线程非安全,静态变量即类变量,位于方法区,为所有该类下的对象共享,共享一份内存,一旦静态变量被修改,其他对象均对修改可见,故线程非安全。

实例变量

实例变量为对象实例私有,在虚拟机的堆中分配,若在系统中只存在一个此对象的实例,在多线程环境下,“犹如”静态变量那样,被某个线程修改后,其他线程对修改均可见,故线程非安全;如果每个线程执行都是在不同的对象中,那对象与对象之间的实例变量的修改将互不影响,故线程安全。

局部变量

线程安全,每个线程执行时将会把局部变量放在各自栈帧的工作内存中,线程间不共享,故不存在线程安全问题。

变量共享

共享变量线程安全问题

可见性

如果一个线程对共享变量值的修改,能够及时的被其他线程看到,叫做共享变量的可见性。如果一个变量同时在多个线程的工作内存中存在副本,那么这个变量就叫共享变量。

Java多线程里对于共享变量的操作往往需要考虑进行一定的同步互斥操作,原来是因为Java内存模型导致的共享内存对于线程不可见。

Java 内存模型规定,将所有的变量都存放在主内存中。

  1. 线程对共享变量的所有操作必须在工作内存中进行,不能直接操作主内存。
  2. 不同线程间不能访问彼此的工作内存中的变量,线程间变量值的传递都必须经过主内存。

Java多线程变量共享与隔离_第1张图片
多个线程同时对主内存的一个共享变量进行读取和修改时,首先会读取这个变量到自己的工作内存中成为一个副本,对这个副本进行改动之后,再更新回主内存中变量所在的地方。

由于CPU时间片是以线程为最小单位,所以这里的工作内存实际上就是指的物理缓存,CPU运算时获取数据的地方;而主内存也就是指的是内存,也就是原始的共享变量存放的位置。

一个线程A对共享变量1的修改对线程B可见,需要经过下列步骤:

  1. 线程A将更改变量1后的值更新到主内存
  2. 主内存将更新后的变量1的值更新到线程B的工作内存中变量1的副本

要实现共享变量的可见性必须保证下列两点:

  1. 线程对工作内存中副本的更改能够及时的更新到主内存上
  2. 其他线程能够及时的将主内存上共享变量的更新刷新到自己工作内存的该变量的副本上

可见性举例

一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辅运算。CPU的每个核都有自己的一级缓存,在有些架构里面还有一个所有CPU都共享的二级缓存。
Java多线程变量共享与隔离_第2张图片
1、线程A首先获取共享变量X的值,由于两级Cache都没有命中,所以加载主内存中X的值,假如为0。然后把X=0的值缓存到两级缓存,线程A修改X的值为1,然后将其写入两级Cache,并且刷新到主内存。线程A操作完毕后,线程A所在的CPU的两级Cache内和主内存里面的X的值都是l。

2、线程B获取X的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了,所以返回X=1;到这里一切都是正常的,因为这时候主内存中也是X=l。然后线程B修改X的值为2,并将其存放到线程2所在的一级Cache和共享二级Cache中,最后更新主内存中X的值为2,到这里一切都是好的。

3、线程A这次又需要修改X的值,获取时一级缓存命中,并且X=l这里问题就出现了,明明线程B已经把X的值修改为2,为何线程A获取的还是l呢?这就是共享变量的内存不可见问题,也就是线程B写入的值对线程A不可见。

共享变量可见性的实现

Java中可以通过synchronized、volatile、java concurrent类来实现共享变量的可见性。

synchronized

使用synchronized可以保证原子性(synchronized代码块内容要么不执行,要执行就保证全部执行完毕)和可见性,修改后的代码为在write和read方法上加synchronized关键字。

JMM关于Synchronized的两条规定:

  1. 线程解锁前,必须把共享变量的最新值刷新到主内存中;
  2. 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量是需要从主内存中重新读取最新的值(加锁与解锁需要统一把锁)。

synchronized 实际上是对访问修改共享变量的代码块进行加互斥锁,多个线程对synchronized代码块的访问时,某一时刻仅仅有一个线程在访问和修改代码块中的内容(加锁),其他所有的线程等待该线程离开代码块时(释放锁)才有机会进入synchronized代码块。

某一个线程进入synchronized代码块前后,执行过程入如下:

  1. 线程获得互斥锁
  2. 清空工作内存
  3. 从主内存拷贝共享变量最新的值到工作内存成为副本
  4. 执行代码
  5. 将修改后的副本的值刷新回主内存中
  6. 线程释放锁

随后,其他代码在进入synchronized代码块的时候,所读取到的工作内存上共享变量的值都是上一个线程修改后的最新值。

注意,synchronized加锁后用到的变量才会从主内存拉取、才会修改后刷新回主内存。

举例说明:
Java多线程变量共享与隔离_第3张图片
该代码程序不会进入死循环,分析执行过程:

  1. 创建o对象;
  2. 创建新线程,异步执行;
  3. 执行while,此时isStop=false;进入循环;
  4. 遇到synchronized锁,得到锁;
  5. 清空主线程的工作内存;
    从主内存拷贝共享变量最新的值到工作内存成为副本(该步骤省略,因为所里面没有代码块(不知道是否可以这么理解));
    执行代码(该步骤省略,因为没有代码);
    将修改后的副本的值刷新会主内存中(该步骤省略,因为没有代码);
  6. 释放锁
  7. 重新执行while,此时工作内存中没有isStop,从主内存中获取最新值,如果isStop=false,继续执行4、5、6、7步骤,知道2秒钟后子线程将isStop值改为true,并刷新回主线程,此时步骤7从主内存中获得的isStop=true,结束while循环,主线程结束。

特别情况:
Java多线程变量共享与隔离_第4张图片
在上个例子的基础上加上一段代码:System.out.println(isStop),查看打印日志:
Java多线程变量共享与隔离_第5张图片
没有达到自己预期的效果:结束前一次貌似应该打印true;
上面代码其实有两种结果:

  1. 最后一次打印isStop为true(几率小)
  2. 最后一次打印isStop为false(几率大)
    具体原因:
    Java多线程变量共享与隔离_第6张图片
    System.out.println(boolean x)方法其实也含有一个synchronized锁。
    结果1出现的情况:where时,isStop=false,第一个锁o对象锁清空工作内存后重新从内存得到isStop=true(几率小)
    结果2出现的情况:where时,isStop=false,第一个锁o对象锁清空工作内存后重新从内存得到isStop=false,println得到的参数值为false(值传递),println的锁PrintStream对象锁清空工作内存,再次where时,isStop=true(几率大)。

volatile

volatile变量每次被线程访问时,都强迫线程从主内存中重读该变量的最新值,而当该变量发生修改变化时,也会强迫线程将最新的值刷新回主内存。这样一来,不同的线程都能及时的看到该变量的最新值。

但是volatile不能保证变量更改的原子性
比如number++,这个操作实际上是三个操作的集合(读取number,number加1,将新的值写回number),volatile只能保证每一 步的操作对所有线程是可见的,但是假如两个线程都需要执行number++,那么这一共6个操作集合,之间是可能会交叉执行的,那么最后导致number 的结果可能会不是所期望的。

举例说明:
Java多线程变量共享与隔离_第7张图片
程序不会进入死循环,原因:
isStop是被volatile修饰的,所有每次while时都是从主内存中获取isStop的值,当子线程2秒钟后修改了isStop的值为true,并刷新进了住内存,此后while从主内存中获取的isStop值为true,结束循环。

AtomicInteger:一个提供原子操作的Integer的类。在Java语言中,++i和i++操作并不是线程安全的,在使用的时候,不可避免的会用到synchronized关键字。而AtomicInteger则通过一种线程安全的加减操作接口。

synchronized和volatile比较

  1. volatile不需要同步操作,所以效率更高,不会阻塞线程,但是适用情况比较窄

  2. volatile读变量相当于加锁(即进入synchronized代码块),而写变量相当于解锁(退出synchronized代码块)

  3. synchronized既能保证共享变量可见性,也可以保证锁内操作的原子性;volatile只能保证可见性

volatile适用情况

  1. 对变量的写入操作不依赖当前值
    比如自增自减、number = number + 5等(不满足)

  2. 当前volatile变量不依赖于别的volatile变量
    比如 volatile_var > volatile_var2这个不等式(不满足)

特殊操作会从主内存中拉取值

  1. 变量被赋值
  2. 变量参与计算(比如加减乘除)
  3. 字符串拼接(验证过)
  4. ++、–等操作
  5. 线程休眠(验证过)

以上操作会从主内存拉去值到工作内存,所有如果在上面的例子的weile循环中有这些操作,不会造成死循环。
Java多线程变量共享与隔离_第8张图片

变量隔离

多线程之间就是因为数据共享在多个线程才导致了线程不安全,这就要求线程间的数据需要隔离,从根本上解决了线程安全问题。

ThreadLocal

提供线程局部变量;一个线程局部变量在多个线程中,分别有独立的值(副本)。

使用ThreadLocal的好处

  1. 达到线程安全
  2. 不需要加锁,提高执行效率
  3. 更高效地利用内存节省开销,用ThreadLocal可以节省内存和开销。
  4. 免去传参的繁琐,不需要每次都传同样的参数,ThreadLocal使得代码耦合度更低,更优雅

ThreadLocal主要方法

主要是initialValue、set、get、remove这几个方法

  • initialValue方法返回当前线程对应的“初始值”,这是一个延迟加载的方法,只有在调用get的时候,才会触发。
  • 当线程第一次使用get方法访问变量时,将调用initialValue方法,除非线程先前调用了set方法,在这种情况下,不会为线程调用本initialValue方法。
  • 通常,每个线程最多调用一次initialValue()方法,但如果已经调用了一次remove()后,再调用get(),则可以再次调用initialValue(),相当于第一次调用get()。
  • 如果不重写initialValue()方法,这个方法会返回null。一般使用匿名内部类的方法来重写initialValue()方法,以便在后续使用中可以初始化副本对象。

ThreadLocal源码分析

Thread、ThreadLocal、ThreadLocalMap三者的关系:
Java多线程变量共享与隔离_第9张图片
每个Thread对象都有一个ThreadLocalMap,每个ThreadLocalMap可以存储多个ThreadLocal。

get方法
get方法是先取出当前线程的ThreadLocalMap,然后调用map.getEntry方法,把本ThreadLocal的引用作为参数传入,取出map中属于本ThreadLocal的value。

注意:这个map以及map中的key和value都是保存在线程中ThreadLocalMap的,而不是保存在ThreadLocal中

getMap方法:
获取到当前线程内的ThreadLocalMap对象。每个线程内都有ThreadLocalMap对象,名为threadLocals,初始值为null。

set方法
把当前线程需要全局共享的value传入

initialValue方法
这个方法没有默认实现,如果要用initialValue方法,需要自己实现,通常使用匿名内部类的方式实现

remove方法
删除对应这个线程的值。

ThreadLocalMap类
ThreadLocalMap类,也就是Thread.threadLocals。

// 此行声明在Thread类中,创建ThreadLocalMap就是对Thread类的这个成员变量赋值
ThreadLocal.ThreadLocalMap threadLocals = null;

ThreadLocalMap 类是每个线程Thread类里面的变量,但ThreadLocalMap这个静态内部类定义在ThreadLocal类中,其中发现这一行代码

private Entry[] table;

里面最重要的是一个键值对数组Entry[] table,可以认为是一个map,键值对:

  • 键:这个ThreadLocal
  • 值:实际需要的成员变量,比如User或者SimpleDateFormat对象

这个思路和HashMap一样,那么我们可以把它想象成HashMap来分析,但是实现上略有不同。

比如处理冲突方式不同,HashMap采用链地址法,而ThreadLocalMap采用的是线性探测法,也就是如果发生冲突,就继续找下一个空位置,而不是用链表拉链

通过源码分析可以看出,setInitialValue和直接set最后都是利用map.set()方法来设置值,最后都会对应到ThreadLocalMap的一个Entry

ThreadLocal注意事项

1.ThreadLocal内存泄漏问题
ThreadLocalMap中的Entry继承自 WeakReference,是弱引用,ThreadLocal可能出现Value泄漏!

什么是内存泄漏:某个对象不再有用,但是占用的内存却不能被回收

弱引用:通过WeakReference类实现的,在GC的时候,不管内存空间足不足都会回收这个对象,适用于内存敏感的缓存,ThreadLocal中的key就用到了弱引用,有利于内存回收。
强引用:我们平日里面的用到的new了一个对象就是强引用,例如 Object obj = new Object();当JVM的内存空间不足时,宁愿抛出OutOfMemoryError使得程序异常终止也不愿意回收具有强引用的存活着的对象。

ThreadLocalMap 的每个 Entry 都是一个对key的弱引用,同时,每个 Entry 都包含了一个对value的强引用,如下:

static class Entry extends WeakReference> {
 /** The value associated with this ThreadLocal. */
     Object value;
     Entry(ThreadLocal k, Object v) {
         super(k); // key值给WeakReference处理
         value = v; // value直接用变量保存,是强引用
     }
 }

正常情况下,当线程终止,保存在ThreadLocalMap里的value会被垃圾回收,因为没有任何强引用了。但如果线程不终止(比如线程需要保持很久),那么key对应的value就不能被回收,因为有以下的调用链:

Thread---->ThreadLocalMap---->Entry(key为null,弱引用被回收)---->value

因为value和Thread之间还存在这个强引用链路,所以导致value无法回收,就可能会出现OOM。

JDK已经考虑到了这个问题,所以在set, remove, rehash方法中会扫描key为null的Entry,并把对应的value设置为null,这样value对象就可以被回收。比如rehash里面调用resize,如果key回收了,那么value也设置为null,断开强引用链路,便于垃圾回收。

 private void resize() {
           ......省略代码
          ThreadLocal k = e.get();
           if (k == null) {
               e.value = null; // Help the GC
           } 
           ......

但是如果一个ThreadLocal不被使用,那么实际上set, remove, rehash方法也不会被调用,如果同时线程又不停止,那么调用链就一直存在,那么就导致了value的内存泄漏。

ThreadLocal如何避免内存泄漏
及时调用remove方法,就会删除对应的Entry对象,可以避免内存remove泄漏,所以使用完ThreadLocal之后,应该调用remove方法。
比如拦截器获取到用户信息,用户信息存在ThreadLocalMap中,线程请求结束之前拦住它,并用remove清除User对象,这样就能稳妥的保证不会内存泄漏。

注意事项

  1. 共享对象问题
    如果在每个线程中ThreadLocal.set()进去的东西本来就是多线程共享的同一个对象,比如static对象,那么多个线程的ThreadLocal.get()取得的还是这个共享对象本身,还是有并发访问问题。
  2. 不要强行使用ThreadLocal
    如果可以不使用ThreadLocal就能解决问题,那么不要强行使用,在任务数很少的时候,可以通过在局部变量中新建对象解决。
  3. 优先使用框架的支持,而不是自己创造
    在Spring中,如果可以使用RequestContextHolder,那么就不需要自己维护ThreadLocal,因为自己可能会忘记调用remove()方法等,造成内存泄漏。

参考文章:
Java多线程里共享变量线程安全问题的原因
Java多线程共享变量控制
Java多线程超详解
ThreadLocal详解

你可能感兴趣的:(java,后端)