18 Java内存模型与线程_JVM同步机制和锁类库实现线程安全

1 线程安全定义

含糊的定义:如果一个对象可以安全地被多个线程同时使用,那它就是线程安全的
严谨的定义:

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。-----From《Java并发编程实战》作者Brian Goetz

2 Java数据与线程安全

从线程安全角度,将Java中各种操作共享的数据分为:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

2.1 不可变

不可变的对象一定是线程安全。

如何保证不可变呢?

  1. 基本数据类型,用final修饰
  2. 对象类型:用final修饰对象中可变的字段

Java中常用的不可变对象:String、Number的部分子类如 Long、Double、BigInteger、BigDecimal等。

为什么String不可变,参考:Java基础类String学习分析

2.2 绝对线程安全

ConcurrentHashMap (我后续会更新ConcurrentHashMap源码分析专题)

2.3 相对线程安全

定义:只能保证对象单次的操作是线程安全,连续调用不能保证线程安全
大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的 synchronizedCollection()方法包装的集合等。

2.4 线程兼容

定义:对象本身并不是线程安全的,但是可以通过在调用端使用同步手段来保证对象在并发环境中可以安全地使用。如集合类ArrayList和HashMap等。

2.5 线程对立

定义:不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。
例子:Thread类的suspend()和resume()方法:如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险

3 Java线程安全支持

JVM同步机制锁类库实现线程安全

3.1 互斥同步

同步:在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用
互斥:实现同步的一种手段。
互斥同步性能开销:互斥同步属于一种悲观的并发策略,无论共享的数据是否真的会出现竞争,它都会进行加锁,引发:用户态到核心态转换、维护锁计数器、检查是否有被阻塞的线程需要被唤醒 等开销

3.1.1 synchronized互斥同步原理

  1. synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。
  2. 执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加1
  3. 执行 monitorexit指令时会将锁计数器的值减1,一旦计数器的值为零,锁随即就被释放
  4. 如果获取对象锁失败,那当前线程阻塞等待,直到锁被释放。

关于同步块的说明:
monitorenter和monitorexit指令,都需要一个reference类型的参数,指明要锁定和解锁的对象。synchronized修饰地方不同,reference取不同的值:

  • 修饰 对象,取这个对象的引用作为reference;
  • 修饰 实例方法,取方法所属对象实例作为reference,
  • 修饰 类方法,取Class对象来作为线程要持有的锁

根据两个monitorenter和monitorexit这两个字节码指令执行过程,可以得出以下推论:

  • 被synchronized修饰的同步块对同一条线程来说是可重入
  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入

3.1.2 Lock接口和ReentrantLock互斥同步原理

参考:JUC锁: LockSupport详解 JUC锁: ReentrantLock详解 这两个专题讲解

3.1.3 synchronized和Lock对比

Lock应该确保在finally块中释放锁,否则一旦同步代码块中抛出异常,则有可能永远不会释放持有的锁。Lock必须由程序员来保证锁释放,而synchronized由Java虚拟机来确保即使出现异常,锁也能被自动释放。

3.2 非阻塞同步

非阻塞同步:乐观并发策略:不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了。如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。乐观并发策略的实现不再需要把线程阻塞挂起

利用处理器指令,实现非阻塞同步

硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap :CAS)
  • 加载链接/条件储存(Load-Linked/Store-Conditional:LL/SC)

CAS的专题分析:JUC原子类: CAS, Unsafe和原子类详解

3.3 无同步方案

如果方法不涉及共享数据,那自然不需要采用同步措施来保证其正确性。因此会有一些代码天生就是线程安全的

3.3.1 可重入代码

代码定义:可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。

特征:

  • 不依赖全局变量、堆数据、公用的系统资源
  • 用到的状态量都由参数中传入
  • 不调用非可重入的方法

3.3.2 线程本地存储

代码定义:共享数据保证只在同一个线程中执行
场景:消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完。

如果变量要被多线程访问,使用volatile关键字将它声明为“易变的”;如果变量线程独享,通过java.lang.ThreadLocal类来实现线程本地存储的功能。

ThreadLocal专题分析:Threadlocal源码解读

posted @ 2022-12-16 15:10  拿了桔子跑-范德依彪  阅读(77)  评论(0编辑  收藏  举报