对象的共享

本文介绍如何共享和发布对象,使它们能够安全地由多个线程同时访问。 两篇博文合起来就形成了构建线程安全类以及通过juc类库构建并发应用程序的重要基础。

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的值,这种现象称为“重排序”(Reordering)。

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。

有种简单方法避免这些复杂的问题:只要有数据在多个线程之间共享,就该使用正确的同步。

1.1 失效数据

除非在每次访问变量时使用同步,否则很可能获得变量的一个失效值。失效值可能不会同时出现:一个线程可能获得一个变量的最新值,而获得另一个变量的失效值。 失效数据还可能导致一些令人困惑的故障,如:意料之外的异常、被破坏的数据结构、不精确的计算、无限循环等。

//非线程安全的可变整数类
@NotThreadSafe
public class MutableInteger {
    private int value;

    public int get() {
        return value;
    }

    public void set(int value) {
        this.value = value;
    }
}

此类非线程安全,因为get和set方法都是在没有同步的情况下访问value的失效值很容易出现:若某线程调用set,则另一个正在调用get的线程可能看到更新后的value值,也可能看不到。

//线程安全的可变整数类
@ThreadSafe
public class SynchronizedInteger {
    @GuardedBy("this") private int value;

    public synchronized int get() {
        return value;
    }

    public synchronized void set(int value) {
        this.value = value;
    }
}

通过对set,get进行同步,可使此类成为一个线程安全的类。仅对set同步不够,调用get的线程仍可能看见失效值。

1.2 非原子的64位操作

对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。所以,当读取该类变量的操作在不同的线程时,很可能会读取到某个值的高32位和另一个值的低32位,造成读取到是一个随机值。除非用关键字volatile来声明它们,或者用锁保护起来。

1.3 加锁和可见性

当某线程执行由锁保护的同步代码块时,可以看到其他线程之前在同一同步代码块中的所有操作结果。如果没有同步,将无法实现上述保证。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性.为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步.

1.4 volatile变量

用于确保将变量的更新操作通知到其他线程,访问volatile变量时不会执行加锁操作,也就不会使执行线程阻塞,是一种比sychronized更轻量级的同步机制.

编译器与运行时都会注意到此变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序.

volatile变量不会被缓存在寄存器或其他处理器不可见的地方,因此在读取volatile变量时总会返回最新写入的值.

从内存可见性来看:写入volatile变量相当于退出同步代码块,读取则相当于进入同步代码块(并不建议过度依赖此特性,通常比使用锁的代码还复杂)

仅当能简化代码的实现及对同步策略的验证时,才该用.若在验证正确性时需要复杂判断可见性,就不要使用!正确使用方式包括:

  • 确保它们自身状态的可见性
  • 确保它们所引用对象的状态的可见性
  • 标识一些重要的程序周期事件的发生(如初始化或关闭)
// 数绵羊
volatile boolean asleep;
...
while(!asleep){
   countSomeSheep();
}
  • 代码分析 一种典型用法:检查某个状态标记判断是否退出循环.示例中,线程试图通过数绵羊方法进入休眠状态.为了使此示例能正确执行,asleep必须为volatile型.否则,当asleep被另一个线程修改时,执行判断的线程却发现不了.亦可使用加锁保证,但代码会很复杂.

虽然方便,但也存在局限性.常用做某个操作完成,发生中断或状态的标志,如上例的asleep标志…但语义不足以确保递增操作的原子性,除非确保只有一个线程对变量执行写操作(后文的原子变量常做一种"更好的volatile变量"). 加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性

当且仅当满足以下所有条件时,才该用volatile变量

  • 对变量的写入操作不依赖变量的当前值,或能确保只有单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中
  • 在访问变量时不需要加锁

2 发布与逸出

发布:使对象能够在当前作用域之外的代码中使用. 发布方式:

  • 将一个指向该对象的引用保存到其他代码可以访问的地方(最简单的就是保存到公有的静态变量)
  • 非私有方法中返回该引用
  • 将引用传递到其他类的方法中

当某个不应该发布的对象被发布时,就被称为逸出.

//使内部的可变状态逸出(不要这样做!!!)
class UnsafeStates {
    private String[] states = new String[]{
            "AK", "AL" /*...*/
    };

    public String[] getStates() {
        return states;
    }
}
  • 代码分析 如此发布states有问题,因为任何调用者都能修改这个数组的内容.states已经逸出了它所在的作用域,因为这个本应是private的变量已经被发布了.
//this引用隐式地在构造函数中逸出
public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
}
  • 代码分析 当ThisEscape发布EventListener时,也隐含发布了ThisEscape实例本身,因为内部类的实例包含了对外部类实例的隐含引用.

构造过程中,另一个常见错误是,在构造器启动一个线程.此时,无论是显式创建(传给构造器)或隐式(内部类),this引用都会被创建的线程共享.在对象尚未完全构造之前,新的线程就可以看见它.在构造器创建线程并无错误,但最好不要立即启动,而是通过start或initialize方法启动.在构造器调用一个可改写的实例方法时,也会导致this引用逸出.

想在构造器注册一个监听器或启动线程,可使用一个私有的构造器和一个公共的工厂方法.如下示例:

public class SafeListener {
    private final EventListener listener;

    private SafeListener() {
        listener = e -> doSomething(e);
    }

    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

3 线程封闭

一种避免使用同步的方式就是不共享数据.

如果仅在单线程内访问数据,就不需要同步,这就被称为线程封闭.线程封闭是程序设计中的考虑因素,必须在程序中实现.Java也提供了一些机制帮助维护线程封闭性,比如局部变量和ThreadLocal类.

3.1 Ad-hoc线程封闭

维护线程封闭性的职责完全由程序实现来承担.

使用volatile变量是实现Ad-hoc线程封闭的一种方式,只要能保证只有单个线程对共享的volatile变量执行写操作,就可以安全地在这些变量上进行“读-改-写”操作,volatile变量的可见性又保证了其他线程能够看到最新的值。

Ad-hoc线程封闭是非常脆弱的,没有语言特性可使对象直接封闭到目标线程.因此在程序中尽量少使用. 在可能的情况下,使用其他更强的线程封闭技术. ##3.2 栈封闭 在栈封闭中,只能通过局部变量才能访问对象. 局部变量的固有属性之一就是封闭在执行线程中 它们位于执行线程的栈中,其他线程无法访问此栈. 即使使用了非线程安全的对象,该对象仍然是线程安全的.

3.3 ThreadLocal类

使用ThreadLocal是一种更规范的线程封闭方式,它能使线程中的某个值与保存值的对象关联起来。提供了get与set等访问接口方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值.

常用于防止对可变的单实例变量或全局变量进行共享.

如下示例,通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接:

//使用TheadLocal来维持线程封闭性
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();
    }
}

当某个频繁执行的操作需要一个临时对象,如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象,就可以使用该技术.

当某个线程初次调用get方法时,就会调用initialValue来获取初始值.可将ThreadLocal < T >看作包含了Map< Thread,T>对象,保存了特定于该线程的值,但ThreadLocal的实现并非如此.这些特定于线程的值存在Thread对象中,当线程终止后,这些值会作为垃圾被回收.

ThreadLocal 变量类似于全局变量,它能降低代码的可重用性,并在类之间引入隐含的耦合性,使用时需要格外小心.

4 不变性

不可变对象:

满足以下条件:

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是final类型(final类型域是不能被修改的)
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)

在被创建后其状态就不能被修改,且必线程安全.

在JMM中,final域能确保初始化过程的安全性,从而可以无限制地访问不可变对象,并在共享这些对象时无须同步.

5 安全发布

任何线程都可在无额外同步情况下安全访问不可变对象,即使在发布时没有使用同步.

然而,若final域所指向为可变对象,访问这些可变对象的状态时仍需同步.

安全发布常用模式

可变对象必须通过安全方式发布,常意味着发布和使用该对象的线程都需同步.

为安全发布,对象的引用以及对象的状态必须同时对其他线程可见. 一个正确构造的对象可以通过以下方式来安全发布

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

线程安全库中的容器类提供了以下的安全发布保证:

  • 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程
  • 通过将某个对象放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或者synchronizedSet中,可以将该对象安全地发布到任何从这些容器中访问该对象的线程 通过将某个对象放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该对象安全地发布到任何从这些队列中访问该对象的线程

通常发布一个静态构造的对象,最简单安全的方式就是使用静态的初始化器:

public static Holder holder = new Holder(42);

由JVM在类的初始化阶段执行,且由于JVM内部存在着同步机制,因此这样初始化的任何对象都能被安全发布.

事实不可变对象:对象从技术上来看是可变的,但其状态在发布后不会再改变. 在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象. 对于可变对象,不仅在发布对象时需要同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性.

对象的发布需求取决于它的可变性:

  • 不可变对象可以通过任意机制来发布。
  • 事实不可变对象必须通过安全方式来发布。
  • 可变对象必须通过安全方式来发布,而且必须是线程安全的或者用某个锁保护起来。

安全的共享对象

实用策略:

  • 线程封闭 线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改
  • 只读共享 在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它.共享的只读对象包括不可变对象和事实不可变对象
  • 线程安全共享 线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公共接口来进行访问而不需要进一步的同步
  • 保护对象 被保护的对象只能通过持有特定的锁来访问.保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象

原文地址:https://cloud.tencent.com/developer/article/2178546

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


学习编程是顺着互联网的发展潮流,是一件好事。新手如何学习编程?其实不难,不过在学习编程之前你得先了解你的目的是什么?这个很重要,因为目的决定你的发展方向、决定你的发展速度。
IT行业是什么工作做什么?IT行业的工作有:产品策划类、页面设计类、前端与移动、开发与测试、营销推广类、数据运营类、运营维护类、游戏相关类等,根据不同的分类下面有细分了不同的岗位。
女生学Java好就业吗?女生适合学Java编程吗?目前有不少女生学习Java开发,但要结合自身的情况,先了解自己适不适合去学习Java,不要盲目的选择不适合自己的Java培训班进行学习。只要肯下功夫钻研,多看、多想、多练
Can’t connect to local MySQL server through socket \'/var/lib/mysql/mysql.sock问题 1.进入mysql路径
oracle基本命令 一、登录操作 1.管理员登录 # 管理员登录 sqlplus / as sysdba 2.普通用户登录
一、背景 因为项目中需要通北京网络,所以需要连vpn,但是服务器有时候会断掉,所以写个shell脚本每五分钟去判断是否连接,于是就有下面的shell脚本。
BETWEEN 操作符选取介于两个值之间的数据范围内的值。这些值可以是数值、文本或者日期。
假如你已经使用过苹果开发者中心上架app,你肯定知道在苹果开发者中心的web界面,无法直接提交ipa文件,而是需要使用第三方工具,将ipa文件上传到构建版本,开...
下面的 SQL 语句指定了两个别名,一个是 name 列的别名,一个是 country 列的别名。**提示:**如果列名称包含空格,要求使用双引号或方括号:
在使用H5混合开发的app打包后,需要将ipa文件上传到appstore进行发布,就需要去苹果开发者中心进行发布。​
+----+--------------+---------------------------+-------+---------+
数组的声明并不是声明一个个单独的变量,比如 number0、number1、...、number99,而是声明一个数组变量,比如 numbers,然后使用 nu...
第一步:到appuploader官网下载辅助工具和iCloud驱动,使用前面创建的AppID登录。
如需删除表中的列,请使用下面的语法(请注意,某些数据库系统不允许这种在数据库表中删除列的方式):
前不久在制作win11pe,制作了一版,1.26GB,太大了,不满意,想再裁剪下,发现这次dism mount正常,commit或discard巨慢,以前都很快...
赛门铁克各个版本概览:https://knowledge.broadcom.com/external/article?legacyId=tech163829
实测Python 3.6.6用pip 21.3.1,再高就报错了,Python 3.10.7用pip 22.3.1是可以的
Broadcom Corporation (博通公司,股票代号AVGO)是全球领先的有线和无线通信半导体公司。其产品实现向家庭、 办公室和移动环境以及在这些环境...
发现个问题,server2016上安装了c4d这些版本,低版本的正常显示窗格,但红色圈出的高版本c4d打开后不显示窗格,
TAT:https://cloud.tencent.com/document/product/1340