保证线程安全的两个角度

想要保证并发访问的安全性,可以从以下两个不同的角度进行思考:

  • 访问状态变量时使用同步,变"多线程"为"单线程",即避免多个线程在同一时刻访问相对数据。
  • 确保被访问的对象是线程安全的。

除以上两种方式,还有一种是不在线程之间共享任何变量,可以使用无状态变量,有如下特点:

  • 不包含任何域。
  • 不包含任何其他类中域的使用。
  • 计算过程中的临时状态仅保存在线程的操作数栈上,不会被保存在堆中。

Servlet就是典型的无状态变量,因为请求的处理要保证并发性。

线程的安全性

线程安全性主要解决了如何避免多个线程在同一时刻访问同一个数据的问题,它主要通过加锁的方式,使得多个线程排成一队,一个一个的访问数据,也是由于这个原因,通过这种方式保证线程安全会对应用的性能产生影响。

加锁:synchronized

synchronized是互斥锁,也就是说,在同一时刻,它只允许一个线程拿到锁对象,它有如下两种方法:

使用方法

对于存在线程安全问题的代码,可以用synchronized修饰它,然后无论有多少个线程同时到达这段代码,线程们都只能排成一列,一个一个的去执行它。

synchronized有两种修饰代码块的方法:修饰代码块修饰方法。

修饰 代码块

csharp 复制代码
synchronized (lock对象) {
    // 同步代码块
}

修饰方法:

采用修饰方法的方法使用synchronized,不意味着就不需要指定锁对象了,只不过Java为我们隐式指定了。

csharp 复制代码
synchronized public void getValue() {...}

// 相当于:
class X {
    synchronized(this) public void getValue() {...}
}

修饰静态方法:(锁是该方法所在的Class对象)

csharp 复制代码
synchronized public static void getValue() {...}

// 相当于:
class X {
    synchronized(X.class) public void getValue() {...}
}

synchronized锁是可重入的

拿到锁的线程可以再次拿到锁,这意味着获取锁的操作粒度是"线程"。

可重入锁的一种实现方式:

  • 为每个锁关联一个获取该锁的次数的计数器count,和一个所有者线程。
  • count=0时,说明没有线程持有该锁。
  • 当一个线程获取一个未被持有的锁时,JVM记下锁的持有者,并count=1。
  • 当这个线程再次获取锁时,count++。
  • 当线程退出同步代码块时,count--。
  • 当count再次减为0时,锁被释放。

如何减小synchronized对应用性能的影响

  • 将不影响共享状态且执行时间较长的操作放在同步代码块外面,尽量让同步代码块中放的是一些执行时间比较短的操作,让持有锁的时间尽可能短。
  • 执行时间较长的计算或者可能无法快速完成的操作时(如:网络I/O或控制台I/O操作),一定不要持有锁。

synchronized的原理

简单解释(通过Happenes-Before)

Happenes-Before中有一条关于锁的规则:监视器锁的解锁操作必须在同一个监视器锁的加锁操作前执行。Java中的Happenes-Before规则表达的是:前面一个操作的结果对后续操作是可见的。这条规则说明,前一个线程的解锁操作对后一个线程的加锁操作是可见的,即前一个线程在临界区修改的共享变量,对后续进入临界区的线程是可见的。

这里的临界区表示被synchronized修饰的代码块。

真正的原理

JVM是基于进入和退出Monitor对象来实现方法同步和代码块同步的。代码块的同步是通过monitorenter和monitorexit实现的,方法同步使用的是另一种方法。

monitorenter会被插入到同步代码块开始的位置,而monitorexit会被插入到方法结束的位置或异常处(并不是同步代码块结束的位置),JVM保证每个monitorenter都会有一个monitorexit与之对应。

当一个线程执行到monitorenter指令时,会尝试获取对象对应的monitor的所有权,任何对象都有一个monitor与之关联,当一个monitor被持有后,该对象所保护的区域将处于锁定状态,因为其他线程这时不能持有montior。

那么接下来问题来了,锁对象到底被存在哪里呢?synchronized 用的锁是存在 Java 对象的对象头中的,先来介绍一下对象头是什么。

对象头

·Java 的对象头主要包含几部分:

长度 内容 说明
32/64 bit Mark Word 存储对象的hashCode和锁信息
32/64 bit Class Metadata Address 存储对象类型数据的指针
32 bit Array length 数组的长度(数组对象才有)

Monitor Record

Monitor Record是线程私有的数据结构,每一个线程都有一个monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中还有一个Owner字段存放拥有该锁的线程的唯一表示。表示该锁被这个线程占用。

Monitor Record的内部结构如下:

monitor record元素 说明
Owner 初始化时为null,表示当前没有任何线程拥有该monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为null
EntryQ 阻塞所有试图锁住monitor record失败的线程
RcThis 表示 blocked 或 waiting 在该线程monitor record上的所有线程的个数
Nest 实现重入锁的计数
HashCode 保存从对象头拷贝过来的 HashCode 值
Candidate 0 表示没有需要唤醒的线程,1 表示要唤醒一个继任线程来竞争锁

锁优化

偏向锁

引入背景: 大多数情况下,锁不仅不存在竞争,而且总是由同一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。(减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁而产生的性能消耗)。

加锁过程:

  • 当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
  • 如果测试成功,表示线程已经获得了锁。
  • 如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁)。
  • 如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

轻量级锁

目的: 是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

获取锁

对象的安全共享

可见性

概述

  • 定义:当一条线程修改了共享变量的值,其他线程可以立即得知这个修改。

  • 实现方式:在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的方法实现,依赖主内存作为传输媒介。

  • 可以保证可见性的关键字:

    • volatile:通过volatile的特殊规则

    • synchronized:通过"对一个变量执行unlock操作前,必须将该变量同步回主内存"这条规则。

    • final:被final修饰的字段,一旦完成了初始化,其他线程就能看到它,并且它也不会再变了。

      • 只要不可变对象被正确的构建出来(没有发生this引用溢出),它就是线程安全的。

失去可见性的危害

  • 用来作为状态判断的量被其他线程修改了,运行的那个线程就是看不见

    typescript 复制代码
     public class NoVisibility {
         private static boolean ready;
         private static int number;
         private static class ReaderThread extends Thread {
             @Override
             public void run() {
                 while (!ready) {
                     Thread.yield(); // 放弃当前CPU资源
                 }
                 System.out.println(number);
             }
         }
         public static void main(String[] args) {
             new ReaderThread().start();
             number = 42;
             ready = true;
         }
     }
     
     /* 
     这段代码可能有一下三种输出:
     1. 正确输出42
     2. 持续循环下去,如ReaderThread放弃当前CPU资源后,立即再次抢到CPU资源
     3. 输出0,因为在没有同步的情况下,Java编译器,处理器及运行时会对操作顺序进行重排序,
        所以number = 42;和ready = true;这两句的执行顺序可能会互换,
        导致ready为true时,number还没被赋值为42
     */
  • 非原子的64位操作:读到的数高位已经被改了,低位还没来得及改

对抗可见性问题的法宝:volatile

volatile变量是用来确保将变量的更新操作通知给其他线程的,即在读取volatile变量时,总会返回最新写入的值是一种比synchronized更轻量级的同步机制。

特点

  • 该变量保证对所有线程的可见性。
  • 禁止指令重排。

特点的实现原理

  • 在volatile变量的赋值操作的反编译代码中,在执行了赋值操作之后加上了一行:lock addl $0x0,(%esp)
  • 这一句的意思是:给ESP寄存器+0,是一个空操作,重点在lock上。
  • 首先lock的存在相当于一个内存屏障,使得重排序时,不能把后面的指令排在内存屏障之前。
  • 同时,lock指令会将当前CPU的Cache写入内存,并无效化其他CPU的Cache,相当于Cache中的变量做了一次store->write操作。
  • 这使得其他 CPU 可以立即看见 volatile 变量的修改,因为其他 CPU 在读取 volatile 变量前会先从主内存中读取 volatile 变量,即进行一次 read -> load 操作。

Java内存模型中对volatile变量定义的特殊规则

在对volatile变量执行read、load、use、assign、store、write操作时:

  • use操作必须与load、read操作同时出现

    • use<-load<-read
  • assign操作必须与store、write操作同时出现

    • assign->store->write
  • 同一个线程进行如下两套动作,可以保证:如果A先于B执行,那么P先于Q执行

    • 第一套:A (use/assign) -> F (load/store) -> P (read/write)
    • 第二套:B (use/assign) -> G (load/store) -> Q (read/write)

与synchronized的区别

  • synchronized:既保证可见性,又保证原子性
  • volatile:只保证可见性(所以count++原子性无法保证)

应用场景

  • 常见的应用:

    • 某个操作完成的标志
    • 发生中断的标志
  • volatile变量不适用的场景:

    • 运算结果依赖于该变量当前的值,比如i++
    • 运算结果依赖于其他状态变量

通过确保状态不被发布来保证安全性

发布与溢出

发布

使对象能在当前作用域之外运行。

发布方法:

  • 最简单的发布方法:public static
  • 将指向该对象的引用保存到其他代码可以访问到的地方。
  • 在某个非私有的方法中返回该引用。
  • 将引用传递到其他类的方法中。

溢出

发布了不该发布的对象。

一个简单的溢出过程

typescript 复制代码
class UnsafeStates {
    private String[] states = new String[] {"AB", "CD"};

    public String[] getStates() {
        return states; // 可以通过这个方法得到states,然后就可以随便修改states,就逸出了
    }
}

this引用溢出

  • 产生原因

    • 在一个对象的构造方法中启动了一个线程,并在这个线程的public方法中调用了这个对象的方法,相当于将还没构造好的对象的this实例泄漏了。
  • 解决方法

    • 私有化构造方法,只在构造方法中写新线程的代码但不start,然后写一个工厂方法newInstance来创建实例,在工厂方法中先调用构造函数创建实例,再启动线程,这样就不会把一个还没有构造好的对象发不出去了。

线程封闭

栈封闭

使用局部变量,并保证这个局部变量不溢出。

ThreadLocal类

类似于对应线程的全局变量,但是每一个线程维护一个自己的该变量对应值

ThreadLocld实现原理

  • 每一个ThreadLocal都有一个唯一的ThreadLocalHashCode。

  • 每一个线程中有一个专门保存这个HashCode的Map<ThreadLocalHashCode,对应变量的值>。

  • ThreadLocal#get()时,实际上是当前线程先拿这个ThreadLocal对象的ThreadLocalHashCode,然后通过这个ThreadLocalHashCode去自己内部的Map中去取值。

    • 即每个线程对应的变量不是存储在ThreadLocal对象中的,而是存在当前线程对象中的,线程自己保管封存在自己内部的变量,达到线程封闭的目的。
    • 也就是说,ThreadLocal对象并不负责保存数据,它只是一个访问入口。

不可变对象

定义

  • 对象创建后,其状态不能被修改。
  • 对象是正确创建的(无this引用溢出)

使用方法

  • 因为对象是不可变的,所以多个线程可以放心大胆的同时访问。
  • 当这个对象中的状态需要被改变时,需要废掉当前的对象,new一个新对象代替现在的旧对象。

final域

final 域是我们用来构造不可变对象的一个利器,因为被它修饰的域一旦被初始化后,就是不可修改的了,不过这有一个前提,就是如果 final 修饰的是一个对象引用,必须保证这个对象也是不可变对象才行,否则 final 只能保证这个引用一直指向一个固定的对象,但这个对象自己的状态是可以改变的,所以一个所有域都是 final 的对象也不一定是不可变对象

2种初始化方式

ini 复制代码
final i = 42;
final i;  // 之后在每一个构造函数中给i赋值

安全发布对象

保证发布的对象的初始化构造过程不会受到任何其他线程干扰,就像加了锁一样,被创建它的线程构造好了,再发布给其他线程。

常用发布模式

  • 在静态块中初始化了一个对象引用。
  • 将对象引用保存到volatile类型的域或者AtomicReference对象中。
  • 将对象引用保存到某个正确构造的对象的final域中。
  • 将对象引用保存到一个被锁保护的域中。

最简单和安全的发布方式

java 复制代码
public static Holder holder = new Holder(42);

可以保证安全的原因:静态变量的赋值操作在加载类的初始化阶段完成,包含在()方法的执行过程中,因此这个过程受到JVM内部的同步机制保护,可以用来安全发布对象。

Java提供的可以安全发布对象的容器

  • Map

    • HashTable

    • ConcurrentMap

    • Collections.SynchronizedMap

      • 使用Collections.synchronizedMap(Map<K,V> m)获得
      • 所有方法都被 synchronized 修饰
  • List

    • Vector

    • CopyOnWriteArrayList

    • CopyOnWriteSet

    • Collections.SynchronizedSet

      • 使用Collections.synchronizedSet(Set<T> s)获得
      • 所有方法都被 synchronized 修饰
  • Queue

    • BlockQueue
    • ConcurrentLinkedQueue
相关推荐
慧都小妮子几秒前
Spire.PDF for .NET【页面设置】演示:打开 PDF 时自动显示书签或缩略图
java·pdf·.net
m51275 分钟前
LinuxC语言
java·服务器·前端
IU宝9 分钟前
C/C++内存管理
java·c语言·c++
瓜牛_gn10 分钟前
依赖注入注解
java·后端·spring
hakesashou11 分钟前
Python中常用的函数介绍
java·网络·python
佚先森20 分钟前
2024ARM网络验证 支持一键云注入引流弹窗注册机 一键脱壳APP加固搭建程序源码及教程
java·html
古月居GYH34 分钟前
在C++上实现反射用法
java·开发语言·c++
儿时可乖了1 小时前
使用 Java 操作 SQLite 数据库
java·数据库·sqlite