原创

Java多线程编程


Java多线程编程

使用多线程的原因有: 提高资源利用率, 公平的共享计算机资源, 编写多任务时的便利性.

线程允许在同一个进程中同时存在多个程序控制流, 线程会共享进程范围内的资源, 每个线程都有各自的程序计数器, 栈以及局部变量等.

在大多数现代操作系统中, 都是以线程为基本的调度单位, 而不是进程. 如果没有明确的协同机制, 那么线程将彼此独立执行.由于同一个进程中的所有线程都将共享进程的内存地址空间, 因此这些线程都能访问相同的变量并在同一个堆上分配对象, 这就需要实现一种比在进程间共享数据粒度更细的数据共享机制. 如果没有明确的同步机制来协同对共享数据的访问, 那么会造成不可预测的结果.

线程的优势:

  1. 发挥多处理器的强大能力: 如果设计正确, 多线程程序可以通过提高处理器资源的利用率来提升系统吞吐率.
  2. 建模的简单性: 可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流, 每个工作流在一个单独的线程中运行, 并在特定的同步位置进行交互.
  3. 异步事件的简化处理: 为每个套接字连接分配各自的线程并且使用同步I/O, 那么会降低开发难度.
  4. 响应更灵敏的用户界面: 现代的GUI框架中, 都采用一个事件分发线程来提高响应的灵敏度.

线程带来的风险:

  1. 安全性问题: 由于多个线程要共享相同的内存地址空间, 并且是并发运行, 因此它们可能会访问或修改其他线程正在使用的变量, 线程会由于无法预料的数据变化而发生错误. 当多个线程同时访问和修改相同的变量时, 将会在串行编程模型中引入非串行因素, 而这种非串行性是很难分析的, 要使多线程程序的行为可以预测, 必须对共享变量的访问操作进行协同.

    如果没有同步, 无论是编译器, 硬件还是运行时, 都可以随意安排操作的执行时间和顺序, 例如对寄存器或者处理器中的变量进行缓存, 而这些被缓存的变量对于其他线程来说是暂时(设置永久)不可见的. 虽然这些有助于实现更优的性能, 但为开发人员带来了负担, 因为开发人员必须找出这些数据在哪些位置被多个线程共享, 只有这样才能使这些优化措施不破坏线程安全性.

  2. 活跃性问题: 当某个操作无法继续执行下去时, 就会发生活跃性问题, 如死锁, 饥饿, 活锁等, 活跃性关注的目标是某件正确的事情最终会发生. 导致活跃性问题的错误同样难以分析, 因为它们依赖于不同 线程的事件发生时序, 因此在开发和测试中并不总是能够重现.

  3. 性能问题: 活跃性意味着某件正确的事情最终会发生, 而性能意味着希望正确的事情尽快发生. 在设计良好的并发应用程序中, 线程能提升程序的性能. 但无论如何, 线程会带来某种程度的运行时开心. 在多线程程序中, 当线程调度器临时挂起活跃线程并转而运行另一个线程时, 就会频繁地出现上下文切换操作, 这种操作将带来极大的开销, 保存和恢复执行上下文, 丢失局部性, 并且CPU时间将更多地花在线程调度而不是线程运行上. 当线程共享数据时, 必须使用同步机制, 而这些机制往往会抑制某些编译器优化, 使内存缓存区中的数据无效, 以及增加共享内存总线的同步流量.

当框架在应用程序中引入并发性时, 通常不可能将并发性局限于框架代码, 因为框架本身会回调应用程序的代码, 而这些代码将访问应用程序的状态. 同样, 对线程安全性的需求也不能局限于被调用的代码, 而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径, 因此对线程安全性的需求将在程序中蔓延开.

框架通过在框架线程中调用应用程序代码将并发性引入到程序中. 在代码中将不可避免地访问应用程序状态, 因此所有访问这些状态的代码都必须是线程安全的.

线程安全性

要编写线程安全的代码, 其核心在于要对状态访问操作进行管理, 特别是对共享的可变的状态的访问.

对象的状态是指存储在状态变量中的数据, 在对象的状态中包含了可能任何影响其外部可见行为的数据.

共享意味着变量可以由多个线程同时访问, 而可变意味着变量的值在对象生命周期内可以发生变化.

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步, 那么程序就会出现错误. 有三种方式可以修复这个问题:

  1. 不在线程之间共享该状态变量
  2. 将状态变量修改为不可变的变量
  3. 在访问状态变量时使用同步.

当设计线程安全的类时, 良好的面向对象技术, 不可修改性以及明晰的不变性规范都能起到一定的帮助作用. 程序状态的封装性越好, 就越容易实现程序的线程安全性, 并且代码的维护人员也越容易保持这种方式.

在某些情况下, 良好的面向对象设计与实际情况的需求不一致, 在这些情况下, 可能需要牺牲一些良好的设计原则, 以换取性能或者对遗留代码的向后兼容. 编写并发应用程序的正确编程方法是: 首先使代码正确运行, 然后再提高代码的速度.

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

无状态对象: 不包含状态信息, 也不包含对其他类中域的引用, 而且计算过程中的临时状态仅存在于线程栈上的局部变量中且只能由正在执行的线程访问. 线程之间没有共享状态, 因此无状态对象一定是线程安全的.

竞态条件

在并发编程中, 由于不恰当的执行时序而出现不正确的的结果的情况, 叫做竞态条件. 当某个计算的正确性取决于多个线程的交替执行时序时, 就会发生竞态条件. 最常见的竞态条件是先检查后执行, 即通过一个可能失效的观测结果来决定下一步的动作.

先检查后执行的一种常见情况就是延迟初始化. 延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行, 同时要确保只被初始化一次.

避免竞态条件, 需要以原子方式(不可分割)执行, 就必须在某个线程修改该变量时, 通过某种方式防止其他线程使用这个变量, 从而确保其他线程只能在修改操作完成之前或之后读取和修改状态, 而不是在修改状态的过程中.

在实际情况中, 应尽可能使用现有的线程安全类来管理类的状态. 与非线程安全的对象相比, 判断线程安全对象的可能状态及其状态转换情况要更为容易, 从而更容易维护和验证线程安全性.

同时需要注意, 当在不变性条件中涉及多个变量时, 各个变量之间不是彼此独立的, 而是某个变量的值会对其他变量的值产生约束, 因此当更新某一个变量时, 需要在同一个原子操作中对其他变量同时进行更新, 即使这些变量都是线程安全对象.

要保持状态的一致性, 就需要在单个原子操作中更新所有相关的状态变量.

同步代码块

Java提供一种内置的锁机制来支持原子性: synchronized 同步代码块和同步方法. 同步代码块包含两部分: 一个作为锁的对象引用, 一个作为由这个锁保护的代码块. 每个Java对象都可以做一个实现同步的锁, 这些锁被称为内置锁或监视器锁. Java内置锁相当于一种互斥体, 是互斥锁, 这意味着最多只有一个线程能持有这种锁. 由于每次只能有一个线程执行内置锁保护的代码块, 因此这个锁保护的同步代码块会以原子方式执行.

Java内置锁是可重入的, 即线程试图获得一个已经由它自己持有的锁, 那么这个请求就会成功. 重入的一种实现方法是为每个锁关联一个计数值和一个所有者线程. 重入进一步提升了加锁行为的封装性, 简化了面向对象并发代码的开发, 也会一定程度上防止死锁情况的发生.

一种常见的错误是认为只有在写入共享变量是才需要使用同步. 因为可见性问题, 其他线程不一定能看到新写入的共享数据.

对象的内置锁与其状态之间没有内在的联系, 虽然大多数类都将内置锁用做一种有效的加锁机制, 但对象的域并不一定要通过内置锁来保护. 线程在获取对象的锁之后, 只能阻止其他线程获得同一个锁, 并不能阻止其他线程访问该对象及其对象的域.

一种常见的加锁约定是将所有的可变状态都封装在对象内部, 并通过对象的内置锁对所有访问可变状态的代码路径进行同步, 使得在该对象上不会发生并发访问. 但如果在添加新的方法或代码路径时忘记了使用同步, 那么这种加锁约定会很容易被破坏.

并非所有数据都需要锁的保护, 只有被多个线程同时访问的可变数据才需要通过锁来保护. 对于每个包含多个变量的不变性条件, 其中涉及的所有变量都需要有同一种锁保护.

如果不加区别地滥用synchronized, 可能导致程序中出现过多的同步, 还可能导致活跃性问题或性能问题.

使用synchronized对整个方法进行同步, 虽然能够确保线程安全性, 但有时付出的代价却很高, 如性能严重下降. 可以通过缩小同步代码块的作用范围, 不仅可以保证性能下降不那么严重, 还可以保证线程安全性. 应尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去, 从而在这些操作的执行过程中, 其他线程可以访问共享状态,

要判断同步代码块的合理大小, 需要在各种设计之间进行权衡, 包括安全性(必须满足), 简单性和性能. 通常在简单性和性能之间存在着相互制约的因素, 当实现某个同步策略时, 一定不要盲目地为了性能而牺牲简单性, 这可能会破坏安全性.

当使用锁时, 应该清楚代码块中实现的功能, 以及在执行该代码块时是否需要很长的时间. 无论是执行计算密集的操作, 还是在执行某个可能阻塞的操作, 如果持有锁的时间过长, 那都会带来活跃性问题或性能问题. 当执行时间较长的计算或者可能无法快速完成的操作时(例如网络I/O), 一定不要持有锁.

对象的共享

可见性

关键字synchronized不仅能用于实现原子性或者确定临界区, 而且还能保证内存可见性. 有时不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态, 而且希望确保当一个线程修改了对象状态后, 其他线程能够看到发生的状态变化.

通常无法确保执行读操作的线程能适时地看到其他线程写入的值, 为了确保多个线程之间对内存写入操作的可见性, 必须使用同步机制.

还有种现象是重排序, 在没有同步的情况下, 编译器, 处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整, 在缺乏足够同步的多线程程序中, 要想对内存操作的顺序进行判断, 几乎无法得出正确的结论.

缺乏同步的程序中可能产生错误结果的一种情况是失效数据. 当一个线程读取时可能会得到失效的值, 更糟糕的是失效值可能不会同时出现: 一个线程可能获得一个变量的最新值, 而获得另一个变量的失效值. 失效值可能会导致一些严重的安全性问题, 活跃性问题或令人困惑的故障.

Java内存模型要求, 变量的读取操作和写入操作必须是原子操作, 但对于非volatile类型的long和double变量, JVM允许将64位的读操作或写操作分解为两个32位的操作. 这样会造成对该变量的读操作和写操作在不同的线程中执行, 那么可能会读取到某个值的高32位和另一个值的低32位. 在多线程程序中使用共享且可变的long和double等类型的变量是不安全的, 除非用关键字volatile来声明它们或者用锁保护起来.

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

volatile变量

Java提供了一种较弱的同步机制即volatile变量, 用来确保将变量的更新操作通知其他线程. 当把变量声明为volatile类型后, 编译器与运行时都会注意到这个变量是共享的, 因此不会将该变量的操作与其他内存操作一起重排序. volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方, 因此在读取volatile类型的变量时总会返回最新写入的值.

虽然volatile变量很方便, 但存在一些局限性, 如不足以确保递增(++)/递减(--)操作的原子性. 仅当volatile变量能简化代码的实现以及对同步策略的验证时, 才应该使用. 如果在验证正确性时需要对可见性进行复杂的判断, 那么就不要使用volatile变量. volatile变量的正确使用方式包括: 确保它们自身状态的可见性, 确保它们所引用对象的状态的可见性, 以及标识一些重要的程序生命周期事件的发生.

加锁机制既可以确保可见性又可以确保原子性, 而volatile变量只能确保可见性和防止重排序. 当且仅当满足以下所有条件时, 才应该使用volatile变量:

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

发布与逸出

发布一个对象的意思是指使对象能够在当前作用域之外的代码中使用. 许多情况下, 要确保对象及其内部状态不被发布. 而在某些情况下, 又需要发布某个对象, 但如果在发布时需要确保线程安全性, 则可能需要同步. 发布内部状态会破坏封装性, 并使得程序难以维持不变性条件. 如果在对象构造完成之前就发布该对象, 就会破坏线程安全性.

当某个不应该发布的对象被发布时, 这种情况被成为逸出. 如下面的代码:

class UnsafeStates {
    private String[] states = new String[] {
        "AK", "AL", ...
    };
    
    public String[] getStates() {
        return states;
    }
}

按照上面代码的方式发布states会出现问题, 因为任何调用者都能修改这个数组的内容, 数组states已经逸出了它所在的作用域, 因为这个本应是私有的变量已经被发布了.

当发布一个对象时, 在该对象的非私有域中引用的所有对象同样会被发布. 一般来说, 如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象, 那么这些其他对象也都会被发布.

当把一个对象传递给某个外部方法时, 就相当于发布了这个对象. 无法知道哪些代码会执行, 也不知道外部方法中究竟会发布这个对象还是会保留对象的引用并在随后由另一个线程使用.

无论其他的线程会已发布的引用执行何种操作, 其实都不重要, 因为误用该引用的风险始终存在. 当某个对象逸出后, 你必须假设有某个类或线程可能会误用该对象. 这正是使用封装的最主要原因: 封装能够使得对程序的正确性进行分析变得可能, 并使得无意中破坏设计的约束条件变得更难.

最后一种发布对象或其内部状态的机制是发布一个内部的类实例. 如下面的代码:

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

当ThisEscape发布EventListener时, 也隐含地发布了ThisEscape实例本身, 因为在内部类的实例中包含了对ThisEscape实例的隐含引用, 即this引用在构造函数中逸出.

当且仅当对象的构造函数返回时, 对象才处于可预测的和一致的状态. 因此当从对象的构造函数中发布对象时, 只是发布了一个尚未构造完成的对象, 这种对象被认为是不正确的构造. 不要在构造过程中使this引用逸出.

在构造过程中this引用逸出的一个常见错误是在构造函数中启动一个线程. 当对象在其构造函数中创建一个线程时, 无论是显式创建还是隐式创建, this引用都会被新创建的线程共享. 在对象未完全构造之前, 新的线程就可以看见它. 在构造函数中创建线程并没有错误, 但最好不要立即启动它, 而是通过一个start或initialize方法来启动.

在构造函数中调用一个可改写的实例方法, 既不是私有方法也不是final方法时, 同样会导致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;
    }
}

线程封闭

当访问共享的可变数据时, 通常需要同步. 一种避免使用同步的方式就是不共享. 如果仅在单线程内访问, 就不需要同步, 这种技术被称为线程封闭, 它是实现线程安全性的最简单方式之一. 如Netty框架和ThreadLocal变量. 但程序员仍然需要负责确保封闭在线程中的对象不会从线程中逸出.

栈封闭是线程封闭的一种特例, 在栈封闭中, 只能通过局部变量才能访问对象, 局部变量的固有属性之一就是封闭在执行线程中, 它们位于执行线程的栈中, 其他线程无法访问到. 如果在线程内部上下文中使用非线程安全的对象, 那么该对象仍然是线程安全的.

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

ThreadLocal对象通常用于防止可变的单实例变量或全局变量进行共享, 每个线程都会拥有属于自己的变量.

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

ThreadLocal对象将自身作为key存放在Thread对象的ThreadLocalMap中, 而ThreadLocalMap并非Map接口的实现, 而是由弱引用的ThreadLocal类型的key和value组成的Entry数组构成.

在实现应用程序框架时大量使用了ThreadLocal. 比如通过将事务上下文保存在静态的ThreadLocal对象中, 当框架代码需要判断当前运行的是哪一个事务时, 只需从这个ThreadLocal对象中读取事务上下文. 这种机制很方便, 因为它避免了在调用每个方法时都传递执行上下文信息.

虽然ThreadLocal类似于全局变量, 但是能降低代码的可重用性, 并在类之间引入隐含的耦合性, 因此在使用时要格外小心. 且需要调用remove方法进行清理不用的内存占用.

不变性

满足同步的另一种方法是使用不可变对象, 如果某个对象在创建后其状态就不能被修改, 那么这个对象就称为 不可变对象. 线程安全性是不可变对象的固有属性之一, 其不变性条件是由构造函数创建的, 只要它们的状态不改变, 那么这些不变性条件就能得以维持.

不可变对象很简单, 只有一种状态, 并且该状态由构造函数来控制. 不可变对象更安全, 不可变对象不会被恶意代码或有问题的代码破坏, 可以安全地共享和发布这些对象.

当满足以下条件时, 对象才是不可变的:

  1. 对象创建后其状态不能修改.
  2. 对象的所有域都是final类型.
  3. 对象是正确创建的, 在对象的创建期间, this引用没有逸出.

在不可变对象内部仍可以使用可变对象来管理它们的状态, 可变对象可封装在不可变对象内部使外部无法对其进行修改.

保存在不可变对象中的程序状态仍然可以更新, 即通过将一个保存新状态的实例来替换原有的不可变对象.

Java内存模型中, final域所引用的对象是可变的, 那么被引用的对象可以被修改的; final域能确保初始化过程的安全性, 从而可以不受限制地访问不可变对象, 并在共享这些对象时无需同步.

即使对象是可变的, 通过将对象的某些域声明为final类型, 仍然可以简化对状态的判断, 因此限制对象的可变性也就相当于限制了该对象可能的状态集合.

除非需要更高的可见性, 否则应将所有的域都声明为私有域, 是一个良好的编程习惯; 除非需要某个域是可变的, 否则应将其声明为final域, 也是一个良好的编程习惯.

对于在访问和更新多个相关变量时出现的竞争条件问题, 可以通过将这些变量全部保存在一个不可变对象中来消除. 如果是一个可变对象, 就必须使用锁来确保原子性. 如果是一个不可变对象, 那么当线程获得了对该对象的引用后, 就不必担心另一个线程会修改对象的状态. 如果要更新这些变量, 那么可以创建一个新的容器对象, 但其他使用原有对象的线程仍然会看到对象处于一致的状态.

安全发布

在某些情况下希望在多个线程之间共享对象, 此时必须确保对象能够安全地发布, 不正确的发布会导致其他线程看到尚未创建完成的对象. 尚未创建完成地对象不拥有完整性, 某个观察该对象的线程将看到对象处于不一致的状态, 然后看到对象的状态突然发生变化, 即使线程在对象发布后还没有修改过它.

即使某个对象的引用对其他线程是可见的, 也并不意味着对象状态对于使用该对象的线程来说一定是可见的. 因此为了确保对象状态能呈现出一致的视图, 就必须使用同步.

另一方面, 即使在发布不可变对象的引用时没有使用同步, 也仍然可以安全地访问该对象. 为了维持这种初始化安全性地保证, 必须满足不可变地所有需求: 状态不可修改, 所有域都是final类型, 以及正确的构造过程.

这种保证还将延伸到被正确创建对象中所有地final类型的域. 在没有额外同步的情况下, 也可以安全地访问final类型的域. 然而, 如果final类型的域所指向的是可变对象, 那么在访问这些域所指向的对象的状态时仍然需要同步.

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

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

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

将对象放入到线程安全的容器内满足上述的最后一条需求. 线程安全库中的容器类提供了安全发布保证.

类库中的数据传递机制, 如Future和Exchanger同样能实现安全发布.

要发布一个静态构造的对象, 最简单和最安全的方式是使用静态的初始化器. 静态初始化器是由JVM在类的初始化阶段执行, 由于在JVM内部存在着同步机制, 因此通过这种方式初始化的任何对象都可以被安全地发布.

如果对象从技术上来看是可变的, 但其状态在安全发布后不会再改变, 那么这种对象称为事实不可变对象. 在这些对象发布后, 程序只需将它们视为不可变对象即可. 在没有额外的同步的情况下, 任何线程都可以安全地使用被安全发布的事实不可变对象.

对于可变对象, 不仅在发布对象时需要使用同步, 而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性.

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

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

在并发程序中使用和共享对象时, 可以使用一些实用的策略, 包括:

  1. 线程封闭: 线程封闭的对象只能由一个线程拥有, 对象被封闭在该线程中, 并且只能由这个线程修改.
  2. 只读共享: 在没有额外同步的情况下, 共享的只读对象可以由多个线程并发访问, 但任何线程都不能修改它. 共享的只读对象包括不可变对象和事实不可变对象.
  3. 线程安全共享: 线程安全的对象在其内部实现同步, 因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步.
  4. 保护对象: 被保护的对象只能通过持有特定的锁来访问. 保护对象包括封装在其他线程安全对象中的对象, 以及已发布的并且由某个特定锁保护的对象.
程序员内功
码出好代码
  • 作者:lzlg520
  • 发表时间:2025-04-29 18:58
  • 版权声明:自由转载-非商用-非衍生-保持署名
  • 公众号转载:请在文末添加作者公众号二维码