简单了解下Java并发编程对象共享的可见性问题

2024-07-26 18:00:05 浏览数 (3)

你好,这里是codetrend专栏“高并发编程基础”。

可见性是一个复杂的属性,因为它经常违背我们的直觉。在单线程环境中,如果先写入某个变量的值,然后在没有其他写入操作的情况下读取该变量,程序总能得到相同的值,这是符合我们的期望的。然而,在多线程环境中,当读操作和写操作在不同的线程中执行时,情况却并非如此。通常情况下无法确保执行读操作的线程能够及时地看到其他线程写入的值,有时甚至是根本不可能的。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制来进行严格的同步和协调。

重排序

指令重排序是指 CPU 为了提高程序的执行效率,在不影响程序执行结果的情况下对指令进行优化重新排列的过程。其中,有些重排序可能会导致程序出现错误,例如多线程环境下的数据竞争问题。

代码语言:java复制
package engineer.concurrent.battle.hshare;

public class ReorderDemo {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        while (true) {
            i  ;
            x = y = a = b = 0;

            Thread one = new Thread(() -> {
                a = 1;
                x = b;
            });

            Thread other = new Thread(() -> {
                b = 1;
                y = a;
            });

            one.start();
            other.start();
            one.join();
            other.join();

            if (x == 0 && y == 0) {
                String result = "第"   i   "次出现乱序,x="   x   ", y="   y;
                System.out.println(result);
                break;
            }
        }
    }
}

如果没有重排序,那么 x 和 y 的值都应该为 1。但是,由于存在重排序,可能会出现以下两种情况之一:

  • x 和 y 的值都为 0,这意味着两个变量的写操作被重排序,导致 x 和 y 的赋值操作之间出现了一个间隔。
  • x 和 y 的值都为 1,这意味着两个线程顺序执行,没有出现重排序。

因此,如果程序输出 x=0, y=0,则说明发生了重排序,否则就没有出现重排序。

实际可能输出的结果如下:

代码语言:txt复制
第237127次出现乱序,x=0, y=0

失效数据

在 Java 多线程编程中,由于多个线程访问同一个共享变量,可能会出现失效数据(Dirty Read)的情况。失效数据指的是多个线程在进行读写操作时,由于缺乏同步机制,导致某些线程读取到了其他线程还未完成的写操作的结果,从而导致数据不正确。

以下是几种常见的导致失效数据的情况:

  1. 多个线程同时对共享变量进行写操作,由于缺乏同步机制,可能会出现数据覆盖的情况,导致部分数据丢失。
  2. 在多线程环境下,某个线程对共享变量进行写操作时,由于缓存不一致,其他线程无法及时感知此次操作,从而读取到旧值。
  3. 在多线程环境下,某个线程对共享变量进行写操作时,由于指令重排优化,其他线程读取到了未完全初始化的变量值,从而导致数据异常。

为了避免失效数据的情况,需要使用合适的同步机制来保证共享变量的可见性和线程安全,例如 synchronized 关键字、Lock 接口等。同时,还可以使用 volatile 关键字来保证变量的可见性和禁止指令重排序,但不能保证原子性。

以下是脏读的一个例子:

代码语言:java复制
package engineer.concurrent.battle.hshare;

public class DirtyReadDemo {
    public static class Counter {
        private int count = 0;
        public void increment() {
            count  ;
        }

        public void decrement() {
            count--;
        }

        public int getCount() {
            return count;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i  ) {
                counter.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i  ) {
                counter.decrement();
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println("Final count: "   counter.getCount());
    }

}

正常结果会输出0,但是脏读的发生也就是读取了失效数据导致最后的结果很难输出0。以下是修复后的代码:

代码语言:java复制
package engineer.concurrent.battle.hshare;

public class DirtyReadDemoFix {
    public static class Counter {
        private int count = 0;
        public synchronized void increment() {
            count  ;
        }

        public synchronized void decrement() {
            count--;
        }

        public synchronized int getCount() {
            return count;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i  ) {
                counter.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i  ) {
                counter.decrement();
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println("Final count: "   counter.getCount());
    }

}

非原子的64位操作

在 Java 多线程编程中,对于一些操作,特别是涉及到 64 位数据类型的操作,可能会出现非原子操作的情况。非原子操作指的是一个操作无法在单个步骤中完成,而是需要多个步骤才能完成。在多线程环境下,如果多个线程同时访问这些非原子操作,可能导致数据不一致或错误结果。

以下是几种常见的非原子的 64 位操作:

  1. 64 位数据类型的读写操作:在 32 位系统中,对于 64 位的 long 和 double 类型数据,由于其跨越了两个字节,可能需要两次读写操作才能完成。如果多个线程同时进行读写操作,就可能出现数据不一致的情况。
  2. 64 位数据类型的原子性操作:Java 中的原子类(Atomic Classes)提供了一些针对基本数据类型的原子操作,如 AtomicLong、AtomicReference 等。然而,对于某些 64 位数据类型的操作,例如 long 类型的自增( )或复杂的计算,无法保证原子性,因为它们需要多个步骤来完成。

为了避免非原子操作导致的问题,可以采用以下方法之一:

  1. 使用锁(synchronized 或 Lock)来保证对共享变量的操作的原子性和互斥性。
  2. 使用 Java 提供的原子类(Atomic Classes)来进行操作,例如 AtomicLong。
  3. 将数据类型拆分为多个原子操作,比如使用 AtomicReference 来保证复合操作的原子性。

例子代码如下:

代码语言:java复制
package engineer.concurrent.battle.hshare;

import java.util.concurrent.atomic.AtomicLong;

public class NonAtomicLongIncrement {
    private static long count = 0L;
    private static AtomicLong atomicCount = new AtomicLong(0L);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i  ) {
                count  ;
                atomicCount.incrementAndGet();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i  ) {
                count  ;
                atomicCount.incrementAndGet();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Non-atomic count: "   count); // 非原子性自增不保证结果正确
        System.out.println("Atomic count: "   atomicCount.get()); // 原子性自增保证结果正确
    }
}

原子操作保证了多线程操作的结果一致。而非原子操作结果出现异常。可能的输出结果如下:

代码语言:txt复制
Non-atomic count: 17809
Atomic count: 20000

加锁与可见性

在 Java 中,加锁和可见性是两个关键概念,用于保证多线程环境下的线程安全和正确性。

加锁(Locking):加锁是一种机制,用于确保在同一时间只有一个线程可以访问共享资源或代码块。

在 Java 中,可以使用关键字 synchronizedLock 接口来实现锁定。

  • synchronized 关键字:可以用于修饰方法或代码块,当线程进入被 synchronized 修饰的方法或代码块时,会自动获取对象锁,并在执行完毕后释放锁,确保同一时间只有一个线程执行该方法或代码块。
  • Lock 接口:Java 提供了 Lock 接口及其实现类,如 ReentrantLock,用于手动获取和释放锁。通过调用 lock() 方法获取锁,并在执行完毕后调用 unlock() 方法释放锁。

使用锁可以确保同一时间只有一个线程访问共享资源,避免竞态条件和数据不一致的问题。

可见性(Visibility):可见性是指当一个线程修改了共享变量的值后,其他线程能够立即感知到这个变化。在多线程环境中,由于线程之间的缓存和优化,可能会出现可见性问题。

Java 提供了一些机制来确保可见性:

  • volatile 关键字:可以用于修饰共享变量,当一个线程修改了 volatile 变量的值后,会立即将新值刷新到主内存,并且其他线程在访问该变量时会从主内存中读取最新的值,而不是使用线程本地缓存的值。
  • synchronizedLock:除了保证原子性和互斥性外,synchronizedLock 也能确保可见性。当一个线程释放锁时,会将对共享变量的修改刷新到主内存中,而其他线程在获取锁时会从主内存中读取最新的值。
  • final 关键字:当一个字段被声明为 final 时,保证了该字段的可见性。对 final 字段的赋值操作在构造函数结束之前完成,其他线程在访问该字段时能够看到该字段的最新值。

通过使用 volatilesynchronizedLock,可以确保共享变量的可见性,使得多线程环境下的线程能够正确地读取和写入共享变量的值。

加锁用于保证同一时间只有一个线程访问共享资源,避免竞态条件。可见性机制则确保当一个线程修改共享变量的值后,其他线程能够立即感知到这个变化。这两个概念都是保证多线程程序正确性的重要手段。

volatile

在Java中,volatile 是一种关键字,用于修饰变量。使用 volatile 关键字修饰的变量具有以下特性:

  1. 可见性:volatile 保证了可见性,即当一个线程修改了 volatile 变量的值时,其他线程能够立即看到最新的值。这是因为被 volatile 修饰的变量会被存储在主内存中,而不是线程的本地缓存中,使得所有线程都能够访问到相同的值。
  2. 禁止指令重排序:volatile 关键字还可以禁止指令重排序优化。在多线程环境下,指令重排序可能导致结果的不确定性,使用 volatile 可以防止这种情况的发生。在 volatile 变量的读写操作前后,会插入内存屏障(memory barrier),确保指令不会进行重排序。

然而,volatile 并不能保证原子性。对于复合操作,例如 num ,如果多个线程同时对 num 进行自增操作,虽然每个线程都会看到最新的值,但由于并发操作的执行顺序不确定,最终结果可能不符合预期。要实现原子性操作,需要使用其他机制,如 synchronizedjava.util.concurrent.atomic 包下的原子类。

下面是一个使用 volatile 关键字的示例:

代码语言:java复制
package engineer.concurrent.battle.hshare;

public class VolatileExample {
    private volatile boolean flag = false;

    public void setFlag(boolean value) {
        flag = value;
    }

    public void printFlag() {
        System.out.println("Flag: "   flag);
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i  ) {
            VolatileExample example = new VolatileExample();

            Thread writerThread = new Thread(() -> {
                example.setFlag(true);
            });

            Thread readerThread = new Thread(() -> {
                while (!example.flag) {
                    // do something
                    System.out.println("Waiting for flag to be set...");
                }
                example.printFlag();
            });

            writerThread.start();
            readerThread.start();
            writerThread.join();
            readerThread.join();
            System.out.println("this time " i  " is done");

        }

    }
}

在上述示例中,有一个 VolatileExample 类,其中声明了一个 volatileflag 变量。在写线程中,将 flag 设置为 true。在读线程中,通过循环不断检查 flag 的值,直到 flag 变为 true,然后打印出 flag 的值。由于 flagvolatile 的,读线程能够立即看到写线程对 flag 的修改,从而结束循环并输出结果。

通过对比 flagvolatile 和不是 volatile的执行while的情况可以看出其中的效果。

情况

执行while的次数

volatile

70次

volatile

341次

需要注意的是,尽管 volatile 关键字提供了可见性和禁止指令重排序的特性,但在某些情况下可能并不足够,仍然需要其他的同步机制来保证线程安全。

关于作者

来自一线全栈程序员nine的探索与实践,持续迭代中。

欢迎评论、点赞、关注。

0 人点赞