• Java并发编程(三)概念介绍


    在构建稳健的并发程序时,必须正确使用线程和锁。但是这终归只是一些机制。要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)可变的(Mutable)状态的访问。

    对象的状态是指存储在状态变量(例如实例或静态域)中的数据。

    对象的状态可能包括其他依赖对象的域。比如某个HashMap的状态不仅是HashMap对象本身,还存储在许多Map.Entry对象中。

    "共享"意味着变量可以由多个线程同时访问,而"可变"则意味着变量的值在其生命周期内可以变化。

    image

    线程安全性在于如何防止数据上发生不可控的并发访问

    一个对象是否需要是线程安全的,取决于它是否被多个线程访问。

    要使对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能导致数据破坏以及其他不该出现的结果。

    协同多个线程对变量访问的同步机制主要有:

    1. 关键字synchronized

    2. 关键字volatile

    3. 显式锁(Explicit Lock)

    4. 原子变量

    协同多线程访问一个可变的状态变量的方法有:

    1. 不在线程之间共享该状态变量

    2. 将状态变量修改为不可变的变量

    3. 在访问状态变量时使用同步

    什么是线程安全?

    一个类在多线程环境下被访问,这个类始终能表现出正确的行为,那么就称这个类是线程安全的。

    在线程安全类中往往都封装了必要的同步机制,因此客户端无须进一步采取措施。

    无状态的对象一定是线程安全的。

    无状态可以极大降低类在实现线程安全性时的复杂性。只有当类在保存一些信息的时候,线程安全才会成为一个问题。

    原子性

    当一个操作被CPU无可分割地执行时就是原子操作。

    典型的非原子性操作就是a++,但是实际上这是一个"读取—修改—写入"的操作序列,并且结果状态依赖于之前的状态。

    在并发编程中,由于不恰当的执行顺序而出现不正确的结果是一种非常重要的情况,就是竞态条件(Race Condition)。

    竞态条件

    当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。最常见的竞态条件就是"先检查后执行(Check-Then-Act)"操作,即通过一个可能失效的观测结果来决定下一步的动作。比如延迟初始化的单例模式

    复合操作

    要避免竞态条件的问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成或者之后读取和修改状态,而不是在修改状态的过程中。为了确保安全性,"先检查后执行"(例如延迟初始化)和"读取—修改—写入"(如递增运算)等必须是原子的。我们把"先检查后执行"(例如延迟初始化)和"读取—修改—写入"等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

    加锁机制

    那么如何确保复合操作以原子的方式执行呢?这就要用到Java提供的加锁机制了。

    Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。(加锁机制以及其他的同步机制的另一个重要方面是:可见性)

    同步代码块包括两部分:

    1. 一个作为锁的对象引用

    2. 一个作为这个锁保护的代码块

    以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法一Class对象作为锁。

    synchronized (lock) {
        // 访问或修改由锁保护的共享状态
    }

    每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

    Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远等待下去。

    由于每次只能有一个线程执行内置锁保护的代码,因此,由这个锁保护的同步代码块会以原子的方式执行,多个线程在执行该代码块时也不会互相干扰。并发环境中的原子性与事务应用程序中的的原子性有着相同的含义——一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正执行由同一个锁保护的同步代码块。

    重入

    当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。"重入"意味着获取锁的操作的粒度是"线程",而不是"调用"。(POSIX中pthread互斥体的获取操作是以"调用"为粒度的)重用的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程所持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,当线程退出同步代码块时,计数器会相应地递减。计数值为0时,这个锁将会被释放。

    重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将会产生死锁。

    用锁来保护状态

    由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。如果在符合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

    一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此。

    对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了避免显式地创建锁对象。你需要自行构造加锁协议或者同步策略实现对共享状态的安全访问,并且在程序中自始至终地使用它们。

    每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

    一种常见的加锁约定是,将所有可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全的类中都使用了这种模式,例如Vector和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。

    然而,这种模式并没有特殊之处,编译器或运行时都不会强制实施这种(或其他的)模式。如果再添加新的方法或者代码路径时忘了使用同步,那么这种加锁协议很容易被破坏。

    并非所有的数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。当某个变量由锁来保护时,意味着每次访问这个变量都要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。

    当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。

    虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还需要额外的加锁机制。此外,每个方法都作为同步方法还可能导致活跃性问题(Liveness)或性能问题(Performance)。

  • 相关阅读:
    js == 和 === 判断原理
    react 渲染原理
    常见的HTTP状态码
    类数组和数组的区别是什么?
    如何判断一个变量是不是数组?
    typeof 是否正确判断类型? instanceof呢? instanceof 的实现原理是什么?
    前端 js data数组转tree数据结构
    Echarts 基础学习
    Vue CLI 4.0 项目搭建
    Echarts Demo
  • 原文地址:https://www.cnblogs.com/tuhooo/p/7909653.html
Copyright © 2020-2023  润新知