Java并发编程实战 之 对象的共享
上一篇介绍了如何通过同步多个线程避免同一时刻访问相同数据,本篇介绍如何共享和发布对象,使它们被安全地由多个进程访问。
1.可见性
通常,我们无法保证执行读操作的线程能看到其他线程写入的值,因为每个线程都由自己的缓存机制。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
public class NoVisibility {
private static boolean ready;
private static int number; private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
} public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
以上代码,看起来会输出42,但事实上很可能根本无法终止,因为读线程永远看不到ready的值;很有可能输出0,因为读线程看到了写入ready的值,却没有看到之后写入number的值,这种现象称为“重排序”。在没有同步的情况下,编译器、处理器、运行时等都有可能对操作的执行顺序进行一些意想不到的调整。
所以,只要有数据在多个线程之间共享时,就应该使用正确的同步。
1.1 失效数据
除非使用同步,否则很可能获得变量的失效值。失效值可能不会同时出现,一个线程可能获得一个变量的最新值,而获得另一个变量的失效值。失效数据还可能导致一些令人困惑的故障,如:意料之外的异常、被破坏的数据结构、不精确的计算、无限循环等等。
1.2 非原子的64位操作
对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。所以,很可能会读取到最新值的高32位和失效值的低32值,造成读取到是一个随机值。除非用关键字volatile来声明它们,或者用锁保护起来。
1.3 加锁和可见性
当某线程执行由锁保护的同步代码块时,可以看到其他线程之前在同一同步代码块中的所有操作结果。如果没有同步,将无法实现上述保证。加锁的含义不仅仅局限于互斥行为,还包括可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步。
1.4 volatile变量
当把变量声明为volatile类型后,编译器和运行时都不会将该变量上的操作也其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者其他处理器不可见的地方,因此在读取volatile变量时总会返回最新写入的值。加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者能确保只用单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
2 发布与泄露
发布一个对象是指,是对象能够在当前作用域之外的代码中使用。发布对象的方式包括:非私用变量的引用、方法调用返回的引用、发布内部类对象隐含外部类的引用等等。当某个不应该发布的对象被发布是,就被称为泄露。
public class ThisEscape {
private int status;
public ThisEscape(EventSource source) {
source.registerListener(new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
status = 1;
} void doSomething(Event e) {
status = e.getStatus();
} interface EventSource {
void registerListener(EventListener e);
} interface EventListener {
void onEvent(Event e);
} interface Event {
int getStatus();
}
}
由于内部类的实例包含了对外部类实例的隐含引用,当ThisEscape发布EventListener时,也隐含发布了ThisEscape实例本身。但在此时,变量status还没有被初始化,造成了this引用在构造函数中泄露。可以使用一个私有的构造函数和一个公共的工厂方法,避免不正确的构造过程:
public class SafeListener {
private int status;
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
status = 1;
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
} void doSomething(Event e) {
status = e.getStatus();
} interface EventSource {
void registerListener(EventListener e);
} interface EventListener {
void onEvent(Event e);
} interface Event {
int getStatus();
}
}
3 线程封闭
一种避免使用同步的方式就是不共享。如果仅在单线程内访问数据,就不需要同步,这就被称为线程封闭。线程封闭是程序设计中的考虑因素,必须在程序中实现。Java也提供了一些机制帮助维护线程封闭,比如局部变量和ThreadLocal。
3.1 Ad-hoc线程封闭
Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。使用volatile变量是实现Ad-hoc线程封闭的一种方式,只要能保证只有单个线程对共享volatile变量执行写入操作,那么就可以安全低在这些变量上进行“读取-修改-写入”操作,volatile变量的可见性又保证了其他线程能够看到最新的值。
Ad-hoc线程封闭是非常脆弱的,因此在程序中尽量少使用。在可能的情况下,使用其他线程封闭技术,比如:栈封闭、ThreadLocal。
3.2 栈封闭
在栈封闭中,只能通过局部变量才能访问对象。它们位于执行线程的栈中,其他线程无法访问到。即使这些对象是非线程安全的对象,它们仍然是线程安全的。然而,值得注意的是,只要编写代码的人才知道哪些对象是栈封闭的。如果没有明确的说明,后续的维护人员很容易错误的泄露这些对象。
3.3 ThreadLocal类
使用ThreadLocal是一种更规范的线程封闭方式,它能是线程中的某个值与保存值的对象关联起来。如下代码,通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接:
public class ConnectionDispenser {
static String DB_URL = "jdbc:mysql://localhost/mydatabase"; private ThreadLocal<Connection> connectionHolder
= new ThreadLocal<Connection>() {
public Connection initialValue() {
try {
return DriverManager.getConnection(DB_URL);
} catch (SQLException e) {
throw new RuntimeException("Unable to acquire Connection, e");
}
};
}; public Connection getConnection() {
return connectionHolder.get();
}
}
从概念上看,你可以将ThreadLocal<T>视为包含了Map<Thread,T>对象,其中保存了特定于改线程的值,但ThreadLocal的实现并非如此。这些特定于线程的值保存在Thread对象中,当线程终止后,这些值会作为垃圾被回收。
4 不变性
如果某个对象在被创建后其状态就不能被修改,那么这个对象就被称为不可变对象。满足同步需求的另一种方法就是使用不可变对象。不可变对象一定是线程安全的。当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能改变
- 对象的所有域都是final类型
- 对象是正确创建的,在对象创建期间,this引用没有泄露
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<String>(); public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
} public boolean isStooge(String name) {
return stooges.contains(name);
}
}
上述代码中,尽管stooges对象是可变的,但在它构造完成后无法对其修改。stooges是一个final类型的引用变量,因此所有的对象状态都通过一个final域访问。在构造函数中,this引用不能被除了构造函数之外的代码访问到。
4.1 final域
final类型的域是不能修改的,但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的。final域的对象在构造函数中不会被重排序,所以final域也能保证初始化过程的安全性。和“除非需要更高的可见性,否则应将所有的域都声明为私用域”一样,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的编程习惯。
4.2 使用volatile类型来发布不可变对象
因式分解Sevlet将执行两个原子操作:
- 更新缓存
- 通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的结果
每当需要一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据:
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors; public OneValueCache(BigInteger i, BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
} public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
当线程获取了不可变对象的引用后,不必担心另一个线程会修改对象的状态。如果要更新这些变量,可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。当一个线程将volatile类型的cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据:
public class VolatileCachedFactorizer implements Servlet {
private volatile OneValueCache cache = new OneValueCache(null, null); public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(i);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
}
5 安全发布
5.1 不正确的发布
像这样将对象引用保存到公有域中就是不安全的:
public Holder holder;
public void initialize(){
holder = new Holder(42);
}
由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态。除了发布对象的线程外,其他线程可以看到Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。
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.");
}
}
上述代码,即使Holder对象被正确的发布,assertSanity也有可能抛出AssertionError。因为线程看到Holder引用的值是最新的,但由于重排序Holder状态的值却是时效的。
5.2 不可变对象与初始化安全性
即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。
5.3 安全发布的常用模式
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全发布:
- 在静态初始化函数里初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReference对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
线程安全库中的容器类提供了以下的安全发布保证:
- 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程。
- 通过将某个对象放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中,可以将该对象安全地发布到任何从这些容器中访问该对象的线程。
- 通过将某个对象放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该对象安全地发布到任何从这些队列中访问该对象的线程。
5.4 事实不可变对象
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为事实不可变对象。在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。例如维护一个Map对象,其中保存了每位用户的最新登录时间:
public Map<String, Date> lastLogin =
Collections.synchronizedMap(new HashMap<String, Date());
如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。
5.5 可变对象
对于可变对象,不仅在发布对象是需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,而且必须是线程安全的或者用某个锁保护起来。
5.6 安全的共享对象
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
- 线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
- 只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公共接口来进行访问而不需要进一步的同步。
- 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
Java并发编程实战 之 对象的共享的更多相关文章
- 【Java并发编程实战】----- AQS(二):获取锁、释放锁
上篇博客稍微介绍了一下AQS,下面我们来关注下AQS的所获取和锁释放. AQS锁获取 AQS包含如下几个方法: acquire(int arg):以独占模式获取对象,忽略中断. acquireInte ...
- 【Java并发编程实战】-----“J.U.C”:CyclicBarrier
在上篇博客([Java并发编程实战]-----"J.U.C":Semaphore)中,LZ介绍了Semaphore,下面LZ介绍CyclicBarrier.在JDK API中是这么 ...
- 【Java并发编程实战】-----“J.U.C”:ReentrantReadWriteLock
ReentrantLock实现了标准的互斥操作,也就是说在某一时刻只有有一个线程持有锁.ReentrantLock采用这种独占的保守锁直接,在一定程度上减低了吞吐量.在这种情况下任何的"读/ ...
- 【Java并发编程实战】-----“J.U.C”:Semaphore
信号量Semaphore是一个控制访问多个共享资源的计数器,它本质上是一个"共享锁". Java并发提供了两种加锁模式:共享锁和独占锁.前面LZ介绍的ReentrantLock就是 ...
- 【java并发编程实战】-----线程基本概念
学习Java并发已经有一个多月了,感觉有些东西学习一会儿了就会忘记,做了一些笔记但是不系统,对于Java并发这么大的"系统",需要自己好好总结.整理才能征服它.希望同仁们一起来学习 ...
- java并发编程实战学习(3)--基础构建模块
转自:java并发编程实战 5.3阻塞队列和生产者-消费者模式 BlockingQueue阻塞队列提供可阻塞的put和take方法,以及支持定时的offer和poll方法.如果队列已经满了,那么put ...
- 《Java并发编程实战》/童云兰译【PDF】下载
<Java并发编程实战>/童云兰译[PDF]下载链接: https://u253469.pipipan.com/fs/253469-230062521 内容简介 本书深入浅出地介绍了Jav ...
- 《java并发编程实战》笔记
<java并发编程实战>这本书配合并发编程网中的并发系列文章一起看,效果会好很多. 并发系列的文章链接为: Java并发性和多线程介绍目录 建议: <java并发编程实战>第 ...
- 《Java并发编程实战》文摘
更新时间:2017-06-03 <Java并发编程实战>文摘,有兴趣的朋友可以买本纸质书仔细研究下. 一 线程安全性 1.1 什么是线程安全性 当多个线程访问某个类时,不管运行时环境采用何 ...
随机推荐
- 从壹开始前后端分离[.NetCore] 37 ║JWT完美实现权限与接口的动态分配
缘起 本文已经有了对应的管理后台,地址:https://github.com/anjoy8/Blog.Admin 哈喽大家好呀!又过去一周啦,这些天小伙伴们有没有学习呀,已经有一周没有更新文章了,不过 ...
- GopherChina第一天小结
GopherChina第一天小结 今天参加了Asta举办的第五届GopherChina,第一天参加完,颇有感受,晚上回来趁着还有记忆,来做一下记录. 写在前面 一早从9点开始,一天下来一共八个主题,各 ...
- 对SVN的落地与实践总结
现今最为流行的Git是管理很几套很成熟的分支管理策略.而SVN确实也有,但结合现公司的实际场景还是做了些调整和变动. 一.分支命名规则 所有分支命名采用小写字母 + 数字 + 特殊符号 组成 项目分支 ...
- 如何使用FluentMigrator进行数据库迁移
标题:如何使用FluentMigrator进行数据库迁移 地址:https://www.cnblogs.com/lwqlun/p/10649949.html 作者: Lamond Lu FluentM ...
- Python的垃圾回收机制(引用计数+标记清除+分代回收)
一.写在前面: 我们都知道Python一种面向对象的脚本语言,对象是Python中非常重要的一个概念.在Python中数字是对象,字符串是对象,任何事物都是对象,而它们的核心就是一个结构体--PyOb ...
- My97DatePicker日期控件,开始时间不能大于结束时间,结束时间不能小于开始时间
在只做项目的时候,需要用到一个日期控件,之前用到过my97,感觉挺好的,兼容性很强,配置也比较容易 当开始时间不能大于结束时间和结束时间不能小于开始时间,这个需要一个判定的,要不然不就乱套了 在my9 ...
- MVC图片上传详解
MVC图片上传--控制器方法 新建一个控制器命名为File,定义一个Img方法 [HttpPost]public ActionResult Img(HttpPostedFileBase shangch ...
- Java基础练习2(构造方法)
1.以下关于面向对象概念的描述中,不正确的一项是() A.在构造方法中,this()只能出现在构造方法第一行位置 B.在构造方法中,super()只能出现在构造方法第一行位置 C.this()和sup ...
- headfirst设计模式(9)—模板方法模式
前言 这一章的模板方法模式,个人感觉它是一个简单,并且实用的设计模式,先说说它的定义: 模板方法模式定义了一个算法的步骤,并允许子类别为一个或多个步骤提供其实践方式.让子类别在不改变算法架构的情况下, ...
- Dotspatial 要素重叠部分去除
private void toolStripButton32_Click(object sender, EventArgs e) { /重叠部分去除操作——测试成功 if (mapMain.Layer ...