与计算机基础相关的线程知识在此略过

线程安全性

相关的定义如下:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在代码中不需要任何额外的同步或者协同,这个类都能够表现出正确的行为,那么称这个类是线程安全的

常见的线程安全对象:

  • 无状态对象

    无状态对象一定是线程安全的,这是因为不存在对于当前对象的任何属性字段的访问;而对于方法的调用将使用一个自有的方法栈,这将使得方法的调用都是线程安全的

    import java.util.Random;
    
    // 一个简单的无状态对象,这个对象只有一个实例方法,通过传入的 seed 生成一个随机数
    public class StateSafeClass {
    public long random(long seed) {
    Random random = new Random(seed);
    return random.nextLong();
    }
    }
  • 原子操作对象

    如果对于一个对象的所有属性的访问都是原子操作,那么这个对象在多线程同时访问时将会是 “线程安全” 的

    常见的原子操作对象有:AtomicIntegerAtomicLongAtomicBoolean

    当使用多个原子操作对象通过组合的方式来构建 “线程安全” 对象时,只有在单个的原子操作中同时更新所有的状态才能确保所构成的类是 “线程安全” 的

  • 不可变对象

加锁机制

可以通过加锁的方式来实现代码块的线程安全性,这是因为在 Java 内存模型中定义了有关于 “锁” 的偏序规则:

监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行(显式锁和内置锁在加锁和解锁等操作上有相同的内存语义)

这是为什么能够通过加锁的方式来实现代码的线程安全性的根本原因

内置锁

内置锁即使用 Java 关键字 synchronized 关键字来包围对应的代码块,使得其中的代码块是线程安全的

使用 synchronized 关键字的示例如下:

public class SynchronizedExample {
// 用于获取监视器锁的对象,具体可以查看有关 Java 对象的结构中 MarkWord 的部分
final Object object = new Object(); // 当前对象的状态
int cnt = 0; // 通过 synchronized 关键字来维护当前对象的状态更新的线程安全性
public void plus() {
// 这里的 object 相当于锁的钥匙,只有一个线程在同一时刻能获取这个钥匙
synchronized (object) {
cnt++;
}
}
}

Java 中的内置锁是可 “重入” 的,可重入的意思是说,如果一个线程获取了一个内置锁,那么在之后的任意时刻(如果这个线程依旧持有这个锁),这个线程就可以再次获取这个锁而进入对应的代码块。

内置锁设计为可重入是选择的必然,查看以下这个例子:

public class Parent {
// synchronized 在修饰方法时,获取的 “钥匙” 为 this,即当前对象
public synchronized void parentDo() {
// do something.......
}
} /*
由于 Child 继承了 Parent,如果内置锁不是可重入的话,那么在在 childDo()
方法中调用父类的 parentDo() 方法将会导致 “死锁”
*/
public class Child extends Parent {
public synchronized void childDo() {
// child do something.....
super.parentDo();
}
}

由于不同的 JVM 的实现各有不同,因此对于内置锁的可重入的实现也各有不同。一般常见的一种实现方法为:为每个锁关联一个获取计数值和一个所有者线程,当计数值为 0 时,那么认为这个锁没有被任何线程持有;每当这个获取了这个锁的线程再次获取这个锁时,就将对应的计数值 + 1,当退出这个锁时,将计数值 -1,当计数值为 0 时,将会释放这个锁

显式锁

ReentrantLock 是一个基于 AQS 的对于 java.util.concurrent.locks.Lock 的具体实现,与使用 synchronized 关键字的内置锁不同的地方在于显式锁提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,由于加锁和释放锁的操作都是显式的,因此被称为 “显式锁”

显式锁具有与内置锁相同的互斥性以及可见性(内存语义)

对象的共享

可见性

依旧是在 JMM 中定义的一些偏序关系:

程序顺序规则:如果程序中操作 A 在操作 B 之前,那么在线程中操作 A 将在 操作 B 之前执行

监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行

volatile 变量规则:对 volatile 变量的写入操作必须在对该变量的读操作之前执行(原子变量与 volatile 变量在读操作和写操作上有着相同的语义)

线程启动规则:在线程上对 Thread.start() 的调用必须在该线程中执行任何操作之前执行

线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从 Thread.join() 中成功返回,或者在调用 Thread.isAlive() 中返回 false

中断规则:当一个线程在另一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt 之前执行(或者抛出 InterruptException,或者调用 isInterruptedinterrupted

终结器规则:对象的构造函数必须在启动该对象的终结器之前执行

传递性:如果操作 A 在操作 B 之前执行,并且 B操作在 C操作之前执行,那么操作 A 必须在 操作 C 之前执行

除了上述描述的规则,在一个 Java 程序中,在 JVM、处理器以及运行运行时都可能对操作的执行顺序进行一些意想不到的调整,这也被称为 “指令重排序”,要正确使用 JMM 定义的相关的偏序规则,才能使得程序按照正常的逻辑运行

最低安全性

当线程在没有进行同步的情况下,可能会读取到一个失效值,但是这个失效的值至少也是之前某个线程设置的值,而不是一个随机值。这种保证也被成为 “最低安全性”

在大多数的情况下,“最低安全性” 总是适用的,但是存在这么一个例外:没有使用 volatile 修饰的 64 位数值变量(doublelong

JMM 要求,变量的读取操作和写入操作都必须是原子操作,但是对于非 volatile 修饰的 longdouble 变量,JVM 允许将 64 位的写操作和读操作分解为两个 32 位的数值的操作,在这种情况下是不满足 “最低安全性” 的

为了解决这个问题,可以通过使用 volatile 关键字修饰对应的变量,或者通过加锁的方式来保护对应的变量的状态,这是因为volatile 可以保证对一个变量的写入操作发生在其它的线程操作读取它之前;加锁则使得对于变量的操作发生在另一个线程操作这个变量之前,加锁是更加强大的 volatileJMM 的偏序关系)

对象的发布与逸出

“对象的发布” 指的是使得对象能够在当前作用域之外的代码中使用,例如,将一个指向该对象的引用保存到其它代码可以访问的地方,或者在一个非私有的方法中返回该引用,或者将引用传递到其它类的方法中。

“对象的逸出” 是指某个不应该发布的对象被发布

以下面的例子为例:

// 一个不安全的发布示例
public class UnsafePublisher {
private String[] states = new String[] {
"Apple", "Orange", "Strawberry", "Watermelon"
}; /*
通过 getStates() 方法就可以获取到内部的 states 属性,调用这个方法的任何客户端都可以直接对
states 的内容进行修改,这种修改是线程非安全的
*/
public String[] getStates() {return this.states;}
}

以上的示例是一个显式的 “逸出” 情况,值得一提的还有隐式地发布 this 对象(当前实例对象)

public class ThisEscape {
public ThisEscape(EventSource source) {
/*
在构造函数中发布 EventListener 时,也会隐式地发布 this,
因为在内部类 EventListener 中包含了对当前 ThisEscape 对象的引用
(非静态内部类的实例对象会带有外部的 this 引用,具体可以查看 《Effective Java》有更加详细的说明)
*/ /*
这里存在的问题是在 ThisEscape 对象在构造过程中就已经逸出了,
因此外部的调用有可能会访问到未初始化完成的 this 对象
*/
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
} void doSomething(Event e) {}
interface EventSource {void registerListener(EventListener e);}
interface EventListener {void onEvent(Event e);}
interface Event {}
}

一种常见的错误就是在构造函数中启动一个线程,这样会导致这个 this 对象被新创建的线程共享,新启动的线程将会看到一个没有构造完全的实例对象!!!

同样,如果在构造函数中调用了一个可改写的实例方法(既不是私有方法,也不是终结方法)都会导致 this 对象的逸出

如果想要在构造函数中启动一个线程或者注册事件监听,那么最好的解决方案为将当前对象的构造函数设计为私有的,通过定义一个工厂方法来获取一个新的实例对象,就能够有效地避免这个问题:

public class SafeListener {
private final EventListener listener; private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
} // 通过这种方式,就不会将构造了一半的实例对象发布到别的地方了
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
} // 省略部分接口的定义。。。。
}

线程封闭

当访问共享的可变数据时,通常都需要使用锁来进行同步,以维持数据状态变化的可见性。除了使用锁来同步的可变数据的方式之外,另一种保护可变数据的方式就是使得数据不共享,每个线程访问自己的数据,这样就从根本上解决了数据在多个线程之间不安全的访问的问题。这中技术也被称为 “线程封闭”, 这是实现线程安全性的最简单的方式之一

当某个对象封闭在一个线程中时,“线程封闭” 将自动实现线程安全性,即使被封闭的对象本身不是线程安全的

实例:JDBCConnection 对象,JDBC 规范并不要求 Connection 对象是线程安全的,因为每个连接在同一时刻只会有一个线程会获取到连接,在处理完对应的任务之后就会将这个释放这个连接,整个过程都只会有一个线程参与,因此并不要求 Connection 的实现必须是线程安全的。当然,如果使用的是线程池的话,那么线程池必须是线程安全的,因为线程池总会被多个线程同时访问

Ad-hoc 线程封闭

Ad-hoc 线程封闭是指,维护线程封闭的职责完全由程序实现来承担

Ad-hoc 线程封闭一般是脆弱的,因为没有任何一种语言特性,能够将对象封闭到目标线程上。在使用 Ad-hoc 线程封闭技术时,通常情况下都是需要将一个系统的子系统设计为单线程子系统,而单线程子系统提供的简便性要胜过 Ad-hoc 线程封闭的脆弱性

volatile 变量的线程封闭:如果能够确保只有单个线程对共享的 volatile 变量执行写入操作,那么就可以安全地在这些共享变量之间执行 “读取—写入—读取” 操作,请回忆一下 JMM 提供的偏序规则,对 volatile 变量的写入操作将会发生在对该变量的读取操作之前,因此在只有单个线程的上下文环境中,相当于使用到了 “线程封闭”

由于 Ad-hoc 线程封闭的脆弱性,因此一般情况下不要使用这种线程封闭方式

栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量来访问对象。局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其它线程无法访问这个栈

具体的一个实例如下:

// 该源代码来自 《Java 并发编程实战》3-9

/*
由于整个方法的所有局部变量都封闭在方法中,因此每个访问该方法的线程都会有一个独立的执行栈来执行对应的逻辑
由于将传入的参数对象生成了一个对应的副本,因此产生由于多个线程同时修改参数对象而产生的缓存一致性问题
*/
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null; /*
由于栈封闭的存在,即使 TreeSet 不是线程安全的,但是由于在当前执行的方法栈中只会有一个线程来访问这个对象,因此这个 loadTheArk 方法依旧是线程安全的
如果 animals 逸出当前方法的作用域范围,将会导致栈封闭被破坏,从而失去线程安全性的保障
*/
animals = new TreeSet<>(new SpeciesGenderComparator()); /*
将传入的参数放入到一个新的容器中,这样就能防止访问参数对象而修改对应的状态
维护线程的安全性
*/
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}

ThreadLocal 对象

维持线程封闭的一种更好的方式是使用 ThreadLoacal 对象,这个类能够使得线程中的某个只能够与保存值的对象关联起来ThreadLocal 对象提供了 getset 方法,这些方法为每个使用该变量的线程都存有一个独立的副本,因此 get 方法总能得到最近执行线程在调用 set 方法时设置的最新值

使用场景:

  1. 防止对可变的单例对象或局部变量进行共享
  2. 当频繁执行的操作需要一个临时对象,而又想避免每次执行时都重新创建一个实例对象

不可变对象

满足同步需求的另一种方法就是使用不可变对象(Immutable Object),在领域驱动设计中也被称为 Value Object

如果一个对象在被创建之后就不能被修改,那么这个对象就被称为 “不可变对象”

一个对象是否是不可变对象,需要同时满足以下三个条件:

  1. 对象创建之后就不能被修改
  2. 对象的所有域都是使用 final 关键字修饰的(String 除外)
  3. 对象是被正常构建的(对象创建期间,this 没有逸出)

安全地发布对象

一个不安全的发布对象的实例:

public class Holder {
private int n; public Holder(int n) {
this.n = n;
} public void assertSanity() {
if (n != n)
throw new AssertionError("This statement is false.");
}
}

乍一看这个类没什么问题,但是在某些情况下执行 assertSanity() 方法时将会抛出 AssertionError,这是因为在构造 Holder 对象之前,由于缺乏足够的可见机制,使得第一次读取到的 n 和第二次读取到的 n 不一致,从而抛出异常

如果要安全地发布一个对象,可以考虑使用以下几种方案:

  1. 在静态代码块中初始化一个对象引用(在 JVM 初始化 Class 对象时完成)
  2. 将对象的引用使用 volatile 修饰或者 AtomicReference 对象中
  3. 使对象的引用保存到某个正确构造对象的 final 类型域中
  4. 将对象的引用保存到一个由锁保护的域中

以上几种方案(除静态代码块外)都是基于 JMM 的偏序规则来保证实例化的对象的可见性,使用静态代码块的方式是通过底层的 JVM 来保证可见性的。

一个实例如下:

public class Holder {
private final static Object object = new Object();
}

上面的实例就是通过在静态代码块中初始化对象来实现对象的可见性的,对应的真实的代码如下:

Compiled from "Holder.java"
public class Holder {
public Holder();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return static {};
Code:
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: putstatic #3 // Field object:Ljava/lang/Object;
10: return
}

参考:

[1] 《Java 并发编程实战》Brain Goetz,Tim Peierl 等

Java 并发编程(一)理论基础的更多相关文章

  1. 【转】Java 并发编程:核心理论

    并发编程是Java程序员最重要的技能之一,也是最难掌握的一种技能.它要求编程者对计算机最底层的运作原理有深刻的理解,同时要求编程者逻辑清晰.思维缜密,这样才能写出高效.安全.可靠的多线程并发程序.本系 ...

  2. (转)Java并发编程:核心理论

    原文链接:https://www.cnblogs.com/paddix/p/5374810.html Java并发编程系列: Java 并发编程:核心理论 Java并发编程:Synchronized及 ...

  3. Java 并发编程:核心理论(一)

    前言......... 并发编程是Java程序员最重要的技能之一,也是最难掌握的一种技能.它要求编程者对计算机最底层的运作原理有深刻的理解,同时要求编程者逻辑清晰.思维缜密,这样才能写出高效.安全.可 ...

  4. Java并发编程实战——读后感

    未完待续. 阅读帮助 本文运用<如何阅读一本书>的学习方法进行学习. P15 表示对于书的第15页. Java并发编程实战简称为并发书或者该书之类的. 熟能生巧,不断地去理解,就像欣赏一部 ...

  5. 【Java并发编程实战】----- AQS(四):CLH同步队列

    在[Java并发编程实战]-–"J.U.C":CLH队列锁提过,AQS里面的CLH队列是CLH同步锁的一种变形.其主要从两方面进行了改造:节点的结构与节点等待机制.在结构上引入了头 ...

  6. 【Java并发编程实战】----- AQS(三):阻塞、唤醒:LockSupport

    在上篇博客([Java并发编程实战]----- AQS(二):获取锁.释放锁)中提到,当一个线程加入到CLH队列中时,如果不是头节点是需要判断该节点是否需要挂起:在释放锁后,需要唤醒该线程的继任节点 ...

  7. 【Java并发编程实战】----- AQS(二):获取锁、释放锁

    上篇博客稍微介绍了一下AQS,下面我们来关注下AQS的所获取和锁释放. AQS锁获取 AQS包含如下几个方法: acquire(int arg):以独占模式获取对象,忽略中断. acquireInte ...

  8. 【Java并发编程实战】-----“J.U.C”:CLH队列锁

    在前面介绍的几篇博客中总是提到CLH队列,在AQS中CLH队列是维护一组线程的严格按照FIFO的队列.他能够确保无饥饿,严格的先来先服务的公平性.下图是CLH队列节点的示意图: 在CLH队列的节点QN ...

  9. 【Java并发编程实战】-----“J.U.C”:CountDownlatch

    上篇博文([Java并发编程实战]-----"J.U.C":CyclicBarrier)LZ介绍了CyclicBarrier.CyclicBarrier所描述的是"允许一 ...

  10. 【Java并发编程实战】-----“J.U.C”:CyclicBarrier

    在上篇博客([Java并发编程实战]-----"J.U.C":Semaphore)中,LZ介绍了Semaphore,下面LZ介绍CyclicBarrier.在JDK API中是这么 ...

随机推荐

  1. matlab关于阶梯图和图窗操作

    1阶梯信号绘制 Matlab 中绘制阶梯图函数:stairs x = [30 33 37 40 37 33 30 27 23 20 23 27 30 30]'; StepNum = length(x) ...

  2. 【WPF】单例软件实现自重启

    原文地址 https://www.cnblogs.com/younShieh/p/17749694.html 如果本文对你有所帮助,不妨点个关注和推荐呀,这是对笔者最大的支持~   在WPF应用程序中 ...

  3. Cython加密python代码防止反编译

    本方法适用于Linux环境下: 1.安装库Cython pip3 install Cython==3.0.0a10 2.编写待加密文件:hello.py import random def ac(): ...

  4. .NET周刊【10月第2期 2023-10-08】

    国内文章 起风了,NCC 云原生项目孵化计划 https://www.cnblogs.com/liuhaoyang/p/ncc-the-wind-rises.html 2016年,我和几位朋友发起了. ...

  5. Go 常用标准库之 fmt 介绍与基本使用

    Go 常用标准库之 fmt 介绍与基本使用 目录 Go 常用标准库之 fmt 介绍与基本使用 一.介绍 二.向外输出 2.1 Print 系列 2.2 Fprint 系列 2.3 Sprint 系列 ...

  6. Dynamics CRM中自定义页面实现附件管理包含下载模板、上传、下载、删除

    前言 附件使用的Dynamics CRM平台本身的注释表annotation存储,将附件转换成二进制字节流保存到数据库中,因自带的注释在页面中显示附件不够直观,特做了一个单独的附件管理自定义页面,通过 ...

  7. .Net Core3中微信退款证书本地调试正常读取证书,在服务器IIS中加载不到证书文件

    如图,在开发微信退款功能时,需要用到微信提供的证书文件.本地开发调试时是正常的,但放到服务器(WinServer 2012 R2)中,则报错提示找不到文件. 网上找了一堆骚操作都没什么卵用,最后在其中 ...

  8. 聊聊魔塔社区MGeo模型的部署与运行

    从现今与今后的发展来看,单一的业务不再仅仅依靠于传统的技术开发,而是应该结合AI模型来应用.实践.只有这样,才能更数智化,更高效化,更贴合时代的发展. 魔塔 社区就类似国外的Hugging Face, ...

  9. 2022.7.12 thecold 讲课纪要

    前言 上午刚学完平衡树,听学长说下午讲 \(LCT\) ,想了想就我这种蒟蒻平衡树还写不明白就搞 \(LCT\) ,绝对会挂,就打算下午去初中集训班摸摸鱼. 一进去就看见了 thecold 学长,真的 ...

  10. Codeforces Round #693 (Div. 3) A~E 题解

    写在前边 链接:Codeforces Round #693 (Div. 3) 没有打,闲的没事补一下题. A. Cards for Friends 链接:A题链接 题目大意: 给定一张\(w*h\)的 ...