解密多线程编程中的原子操作:确保数据安全和一致性

478 阅读6分钟

引言

当多个线程同时访问和修改共享数据时,确保操作的原子性是至关重要的。原子操作是不可分割的操作,要么全部执行成功,要么完全不执行。它在多线程环境下保证数据一致性和线程安全性。本文将介绍原子操作的概念、重要性和优化方法。让我们深入了解多线程编程中的原子性和原子操作。在多线程环境下,多个线程可能同时访问和修改共享的数据,如果没有适当地保护和管理这些共享数据,就会出现数据竞争、并发错误和不确定的结果。

本文将介绍原子操作的概念、重要性和优化方法。让我们深入了解多线程编程中的原子性和原子操作。而原子操作作为一种机制,能够提供一致性和可靠性的数据访问,从而避免了这些问题的发生。

本文将详细介绍原子操作和原子性的概念,解释为何原子性是如此重要,并提供优化原子操作的方法,以帮助读者更好地理解和应用于实际的多线程编程中。

什么是原子操作和原子性

原子(Atomic)的字面意思是不可分割(Indivisible)。 对于涉及共享变量访问的操作,若该操作从其执行线程之外的任意线程来看是不可分割的。 那个这个操作就是原子操作,我们称这个操作具有原子性。

不可分割的含义

原子性的一层含义是指,在访问(读、写)某个共享变量的操作从其执行线程以外的任意线程来看,该操作要么已经执行完成,要么尚未发生,其他线程不会看到执行了部分的中间效果。举个例子:

我们(用户)去超市购买商品,使用支付宝进行支付,支付宝支付操作涉及查询账户余额,扣除账户余额,并生成交易日志等一系列操作。但从我们(用户)的角度来看支付就是一个操作,要么支付成功,即我们成功付了钱,要么支付失败,即我们没有支付钱。

在这个例子中,用户的的账户余额就是共享变量,支付宝和用户就分别相当于上述定义中的原子操作的执行线程和其他线程,我们可以说,例子中的支付操作就是一个原子操作,这个操作具有原子性。

另一层含义是指线程之间无法交错访问同一组共享变量。假设O1和O2是访问共享变量v的两个原子操作,这两个操作并非读操作,那么一个线程在执行O1期间(开始执行但没有执行完成),其他线程将无法执行O2。也就是说访问同一组共享变量的原子操作无法交错执行。这可以避免一个线程执行一个操作的期间另一个线程读取或者更新操作所访问的共享变量而导致的干扰(读脏数据)和冲突(丢失更新)的可能。 下面根据代码进行分析:

    public class AtomicityDemo {
        HostInfo hostInfo;

        public AtomicityDemo(String ip,int port) {
            this.hostInfo = new HostInfo();
            hostInfo.setIp(ip);
            hostInfo.setPort(port);
        }

        public void connectToHost(){
            String ip = hostInfo.getIp();
            int port = hostInfo.getPort();
            connectToHost(ip,port);
        }

        private void connectToHost(String ip,int port){
            //...
        }

        public void updateHostInfo(String ip,int port){
            hostInfo.setIp(ip);  //语句1
            hostInfo.setPort(port);  //语句2
        }


        public static void main(String[] args) {
            //启动-开启Main线程,执行下面代码
            AtomicityDemo atomicityDemo = new AtomicityDemo("192.168.1.1", 10086);
            Thread thread = new Thread(() -> {
                atomicityDemo.connectToHost();
            });
            thread.start(); //启动thread线程
            atomicityDemo.updateHostInfo("192.168.1.3",3304);
        }
    }

    class HostInfo{
        String ip;
        int port;

        //get and set
    }

假设上述代码是一个连接主机(服务器)的操作,使用hostInfo进行保存指定主机信息,通过指定的ip和port能够连接到主机。

Main方法中,主线程通过执行updateHostInfo来更新主机信息,而thread线程则通过执行connectToHost方法来连接主机,为了避免线程间的干扰,updateHostInfo方法必须是一个原子操作,否则可能出现以下情况:

假设主线程执行updateHostInfo试图将hostInfo更新位ip地址为“192.168.1.3”和端口号3304的主机时,thread线程可能刚好执行connectToHost方法,由于主线程可能正好执行完语句1而未开始执行语句2(只更新完了ip地址,还没有更新端口号),因此thread线程可能读到的ip地址为“192.168.1.3”,端口号仍然为10086(因为ip地址为“192.168.1.3”的主机上绑定的监听端口是3304,并不是10086),从而无法建立连接。

要解决这个问题,我们可以使用一下两种方式实现原子性:

  1. 使用synchronized关键字来实现锁,确保只有一个县城能够访问共享变量。锁具有排他性,保证在任意时刻只能有一个线程访问该共享变量。

        public synchronized void updateHostInfo(String ip,int port){
            hostInfo.setIp(ip);  //语句1
            hostInfo.setPort(port);  //语句2
        }
    
  2. 使用CAS(compare-and-swap)指令,CAS指令直接在硬件(处理器和内存)的层面上执行原子类操作,它可以被看作是“硬件锁”

        //利用java对变量写操作的原子性的保障,可以通过改写updateHostInfo实现
        public void updateHostInfo(String ip,int port){
            HostInfo newHostInfo = new HostInfo();
            newHostInfo.setIp(ip);
            newHostInfo.setPort(port);
            this.hostInfo = newHostInfo;
        }
    

    需要注意的是,Java中对变量的写操作在64位计算机上是原子性的,而在32位计算机上吗,除了long和double类型之外的其他类型变量的写操作也是原子性的。

提示:

  • 原子操作是针对访问共享变量的操作而言的。仅涉及局部变量访问的操作无关是否是原子的。

  • 原子操作是从操作的执行线程以外的线程来描述的,它只在多线程环境下才有意义。在单线程环境下,一个操作是否具有原子性无关紧要。

扩展思考

原子操作 + 原子操作 != 原子操作

通过上面的更新主机ip地址和端口号的方法可以使我们得到答案。

    public void updateHostInfo(String ip,int port){
        hostInfo.setIp(ip);  //语句1
        hostInfo.setPort(port);  //语句2
    }

需要注意的是,语句1和语句2都是写操作,但是将它们组合成updateHostInfo方法时,并不具有不可分割的特性,可以被读取到中间状态,即执行完语句1而未执行语句2。因此updateHostInfo并不是一个原子操作。