对象共享:Java并发环境中的烦心事

简介: 相关文章: 多线程安全性:每个人都在谈,但是不是每个人都谈地清并发的意义在于多线程协作完成某项任务,而线程的协作就不可避免地需要共享数据。今天我们就来讨论下如何发布和共享类对象,使其可以被多个线程安全地访问。

相关文章: 多线程安全性:每个人都在谈,但是不是每个人都谈地清

并发的意义在于多线程协作完成某项任务,而线程的协作就不可避免地需要共享数据。今天我们就来讨论下如何发布和共享类对象,使其可以被多个线程安全地访问。

之前,我们讨论了同步操作在多线程安全中如何保证原子性,其实关键字synchronized不光实现了原子性,还实现内存可见性(Memory Visibility)。也就是在同步的过程中,不仅要防止某个线程正在使用的状态被另一个线程修改,还要保证一个线程修改了对象状态之后,其他线程能获得更新之后的状态。

1. 内存可见性

在单个线程环境中,对某个变量写入值后,在没有其他写操作的情况下,读取该变量的值总是相同;但是在多线程环境中情况并非如此,虽然难以接受且违反直观,但是很多问题就是这样发生的,这都是由于没有使用同步机制保证可见性。

public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        public void run() {
            //内部静态类可以直接使用外部类的静态域
            while (!ready){
                // 线程让步,使当前线程从执行状态(运行状态)变为可执行态(就绪状态)。
                // 就是说当一个线程使用了这个方法之后,它就会把自己CPU执行的时间让掉,
                // 让自己或者其它的线程运行。
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        //JVM可能对一些语句进行重排序
        number = 42;
        ready = true;
    }
}

上面的期望的代码结果是:因主线程执行ready = true,匿名子线程退出循环,打印number。但是很可能事与愿违:由于匿名线程和主线程并不是一个线程环境,虽然主线程中更新了ready变量的值,但是由于缺少同步机制,更新之后的值不一定对匿名子线程是可见的,匿名子线程很可能就由于使用了失效的数据而不能正常工作.

失效数据是由于Java的内存机制导致的:在没有同步机制的情况下,在多线程的环境中,每个进程单独使用保存在自己的线程环境中的变量拷贝。正因如此,当多线程共享一个可变状态时,该状态就会有多份拷贝,当一个线程环境中的变量拷贝被修改了,并不会立刻就去更新其他线程中的变量拷贝。

有些情况下,上面的程序会输出0,这是由于重排序的发生,也就是JVM根据优化的需要调整“不相关”代码的执行顺序。在主线程中,number = 42ready = true看似是不相关的,不相互依赖,所以可能被JVM在编译时颠倒执行顺序,所以才会出现这个奇怪结果。

重排序和变量多拷贝可能看上去是一种奇怪的设计,但是这样做的目的是希望JVM能充分利用多核处理器强大的性能,Java内存模型更为具体的内容将会在未来的篇章中为大家详细介绍。

1.1 加锁和可见性

正像前文提到同步控制那样,加锁的含义也不仅仅局限于建立互斥性以保证原子性,还涉及到内存可见性。为确保所有线程都能看到共享变量的最新值,所有对该变量执行读操作和写操作的线程都必须在同一个锁上同步

1.2 Volatile变量

加锁当然是多线程安全的完备方法,但是有的时候只需要确保少数状态变量的可见性即可,使用加锁机制未免有些大材小用,因此Java语言提供一种稍弱的同步机制——Volatile变量。当变量被声明为Volatile类型后,在编译时和运行时,JVM都会注意到这是一个共享变量,既不会在编译时对该变量的操作进行重排序,也不会缓存该变量到其他线程不可见的地方,保证所有线程都能读取到该变量的最新状态。

访问Volatile变量时并没使用加锁操作,不会阻塞线程的运行,所以性能远远优于同步代码块和上锁机制,只比访问正常变量略高,不过这是牺牲原子性为代价的。

加锁机制可以确保可见性、原子性和不可重排序性,但是Volatile变量只能确保可见性不可重排序性

使用Volatile变量时需要谨慎,一定要确保以下所有条件:

  1. 对当前变量的写操作,不依赖变量的当前值(比如++操作就不符合要求),或者确保只有一个进程更新该变量状态;
  2. 该变量不会和其他变量一起纳入不变性条件中;
  3. 访问该变量不需要加锁;

实际使用中,Volatile变量多使用在会发生状态翻转的标志位上。

2. 发布与逸出

对象的可见性是保证对象的最新状态被共享,同时我们还应该注意防止不应该被共享的对象被暴露在多线程环境中。

发布对象意味着该对象能在当前作用域之外的代码中被使用,比如,将类内部的对象传给其他类使用,或者一个非私有方法返回了该对象的引用等等。Java中强调类的封装性就是希望能合理的发布对象,保护类的内部信息。发布类内部状态,在多线程的环境下可能问题不大,但是在并发环境中却用可能严重地破坏多线程安全。

某个不该发布的对象被发布了,这种情况被称为逸出.
我们来一起看看几种逸出的例子:

class UnsafeStates {
    private String[] states = new String[]{
        "AK", "AL" /*...*/
    };

    public String[] getStates() {
        return states;
    }
}

上面的例子中,虽然states是私有变量,但是其被共有方法所暴露,数组中的元素都可以被任意修改,这就是一种逸出的情况。

当一个对象被发布时,该对象的非私有域中的所有引用都会被发布,即间接发布

有一种逸出是比较隐蔽的,就是This逸出:

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
}

内部的匿名类是隐私持有外部类的this引用的,这就无意中将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;
    }
}

上例中,外部类的构造器被设置为私有的,其他类执行外部类的公有静态方法在构造器执行完毕之后才返回对象的引用,避免了this对象的逸出问题。

相对而言,对象安全发布的问题比可见性问题更容易被忽视,接下来就讨论下如何才能安全发布对象。

3. 线程封闭

对象的发布既然是个头疼的问题,所以我们应该避免泛滥地发布对象,最简单的方式就是尽可能把对象的使用范围都控制在单线程环境中,也就是线程封闭

常见的线程封闭方法有:

  1. Ad-hoc线程封闭,也就是维护线程封闭性的责任完全由编程承担,这种方法是不推荐的;
  2. 局部变量封闭,很多人容易忽视一点,局部变量的固有属性之一就是封闭在执行线程内,无法被外界引用,所以尽量使用局部变量可以减少逸出的发生;
  3. ThreadLocal,这是一种更为规范的方法,该类将把进程中的某个值和保存值的对象关联起来,并提供get和set方法,保证get方法获得的值都是当前进程调用set方法设置的最新值。

需要说明的是,看起来是ThreadLocal类似于一种 Map<Thread, T>对象,来保存特定于线程的值,但实际上这些值** **,其生命周期和Thread对象一致,一旦线程终止后,线程对象中的值都会被回收。

ThreadLoacl在JDBC和J2EE容器中有着大量的应用。比如,在JDBC中,ThreadLoacl用来保证每个线程只能有一个数据库连接,再如在J2EE中,用以保存线程的上下文,方便线程切换等。

4. 不变性

如果一定要将发布对象,那么不可变的对象是首选,因为其一定是多线程安全的,可以放心地被用来数据共享。这是因为不变的对象的状态只有一种状态,并且该状态由其构造器控制。

对象不可变要求满足以下条件:

  1. 该对象是正确创建的,没有this逸出问题;
  2. 该对象的所有状态在创建之后不能修改,也就是其set方法应该为私有的,或者该域直接是final的。

下面这个类就是不可变的:

@Immutable
 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);
    }

    public String getStoogeNames() {
        List<String> stooges = new Vector<String>();
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
        return stooges.toString();
    }
}

《Effective Java》建议在类设计时应该尽可能减少可变的域:除非必须,域都应该是私有域;除非可变,域都应该是final域。

5. 安全发布

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

  1. 在静态初始化函数中初始化一个对象的引用(态初始化函数由JVM在初始化阶段执行,JVM为其提供同步机制);
  2. 将对象的引用保存在Volatile域或AtomicReference对象中;
  3. 将对象的引用保存在某个正确构造对象的final域中;
  4. 将对象的引用保存到一个由锁保护的域中;
  5. 将对象的引用保存到线程安全容器中;

6. 总结

在讨论过可见性和安全发布之后,我们来总结下安全共享对象的策略:

  1. 线程封闭:线程封闭的对象只能由一个线程拥有,对象封闭在线程中,并且只能由该线程修改。
  2. 只读共享:共享不可变的只读对象,只要保证可见性即可,可以不需要额外的同步操作。
  3. 线程安全共享:线程安全的对象在其内部封装同步机制,多线程通过公有接口访问数据;对象发布的内部状态必须是安全发布的,且可变的状态需要锁来保护;对象的引用和对象的状态都是可见的。

后续预告:Java内存模型

相关文章
|
28天前
|
安全 Java 编译器
Java对象一定分配在堆上吗?
本文探讨了Java对象的内存分配问题,重点介绍了JVM的逃逸分析技术及其优化策略。逃逸分析能判断对象是否会在作用域外被访问,从而决定对象是否需要分配到堆上。文章详细讲解了栈上分配、标量替换和同步消除三种优化策略,并通过示例代码说明了这些技术的应用场景。
Java对象一定分配在堆上吗?
|
2月前
|
Java API
Java 对象释放与 finalize 方法
关于 Java 对象释放的疑惑解答,以及 finalize 方法的相关知识。
48 17
|
22天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
27天前
|
Java 数据库连接 数据库
如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面
本文介绍了如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面。通过合理配置初始连接数、最大连接数和空闲连接超时时间,确保系统性能和稳定性。文章还探讨了同步阻塞、异步回调和信号量等并发控制策略,并提供了异常处理的最佳实践。最后,给出了一个简单的连接池示例代码,并推荐使用成熟的连接池框架(如HikariCP、C3P0)以简化开发。
48 2
|
1月前
|
存储 安全 Java
Java编程中的对象序列化与反序列化
【10月更文挑战第22天】在Java的世界里,对象序列化和反序列化是数据持久化和网络传输的关键技术。本文将带你了解如何在Java中实现对象的序列化与反序列化,并探讨其背后的原理。通过实际代码示例,我们将一步步展示如何将复杂数据结构转换为字节流,以及如何将这些字节流还原为Java对象。文章还将讨论在使用序列化时应注意的安全性问题,以确保你的应用程序既高效又安全。
|
2月前
|
存储 Java 数据管理
Java零基础-Java对象详解
【10月更文挑战第7天】Java零基础教学篇,手把手实践教学!
29 6
|
2月前
|
Oracle Java 关系型数据库
重新定义 Java 对象相等性
本文探讨了Java中的对象相等性问题,包括自反性、对称性、传递性和一致性等原则,并通过LaptopCharger类的例子展示了引用相等与内容相等的区别。文章还介绍了如何通过重写`equals`方法和使用`Comparator`接口来实现更复杂的相等度量,以满足特定的业务需求。
25 3
|
2月前
|
Java
【编程进阶知识】揭秘Java多线程:并发与顺序编程的奥秘
本文介绍了Java多线程编程的基础,通过对比顺序执行和并发执行的方式,展示了如何使用`run`方法和`start`方法来控制线程的执行模式。文章通过具体示例详细解析了两者的异同及应用场景,帮助读者更好地理解和运用多线程技术。
29 1
|
2月前
|
存储 Java
Java编程中的对象序列化与反序列化
【10月更文挑战第9天】在Java的世界里,对象序列化是连接数据持久化与网络通信的桥梁。本文将深入探讨Java对象序列化的机制、实践方法及反序列化过程,通过代码示例揭示其背后的原理。从基础概念到高级应用,我们将一步步揭开序列化技术的神秘面纱,让读者能够掌握这一强大工具,以应对数据存储和传输的挑战。
|
1月前
|
存储 缓存 NoSQL
一篇搞懂!Java对象序列化与反序列化的底层逻辑
本文介绍了Java中的序列化与反序列化,包括基本概念、应用场景、实现方式及注意事项。序列化是将对象转换为字节流,便于存储和传输;反序列化则是将字节流还原为对象。文中详细讲解了实现序列化的步骤,以及常见的反序列化失败原因和最佳实践。通过实例和代码示例,帮助读者更好地理解和应用这一重要技术。
31 0