MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java内存模型与线程安全

2022-02-275.0k 阅读

Java内存模型基础

在Java中,内存模型定义了线程和主内存之间的交互规则。Java内存模型(Java Memory Model,JMM)的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量(Variables)与Java编程中的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为局部变量是线程私有的,不会被共享,也就不存在竞争问题。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。这里的主内存与物理硬件的内存并不完全等同,它只是一个抽象的概念。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

主内存与工作内存交互操作

JMM定义了8种操作来完成主内存与工作内存之间的交互:

  1. lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  5. use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  7. store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。

JMM要求这8种操作都具有原子性,也就是不可再分。例如,read和load必须连续执行,store和write也必须连续执行。但是,对于lock和unlock操作,JMM允许虚拟机实现稍微放松的规则:如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。如果对一个变量执行unlock操作,那必须在此之前把此变量同步回主内存(执行store和write操作)。

内存间交互操作规则

除了上述8种操作,JMM还定义了一系列规则来保证这些操作的正确执行:

  1. 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  2. 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存。
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  5. 一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值。
  7. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  8. 对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

Java内存模型中的原子性、可见性与有序性

  1. 原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的(例外就是long和double的非原子性协定,下面会提到)。如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

下面通过一个简单的示例代码来展示原子性问题:

public class AtomicityTest {
    private static int count = 0;

    public static void increment() {
        count++;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increment();
                }
            });
            threads[i].start();
        }

        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println("Count should be 1000000, but actually is " + count);
    }
}

在上述代码中,count++看似是一个原子操作,但实际上它包含了读取、增加和写入三个步骤,不是原子性的。在多线程环境下,就会出现数据不一致的问题。运行这段代码,输出的结果往往小于1000000。

  1. 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在Java内存模型中,通过volatile关键字来保证可见性。当一个变量被声明为volatile时,它会有以下特性:
    • 保证此变量对所有线程的可见性,这里的“可见性”是指当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
    • 禁止指令重排序优化。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。

以下是一个展示可见性问题的代码示例:

public class VisibilityTest {
    private static boolean stop = false;

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            while (!stop) {
                // do something
            }
            System.out.println("Thread stopped.");
        });
        thread.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        stop = true;
        System.out.println("Stop flag set to true.");
    }
}

在上述代码中,stop变量没有被声明为volatile。当主线程修改stoptrue后,子线程可能无法立即感知到这个变化,导致子线程可能会一直循环下去。如果将stop声明为volatile,就可以保证子线程能及时感知到stop的变化。

  1. 有序性(Ordering):Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,此规则决定了持有同一个锁的两个同步块只能串行地进入。

下面通过一个指令重排序的示例来理解有序性问题:

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

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            a = 0; b = 0;
            x = 0; y = 0;
            Thread thread1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread thread2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            if (x == 0 && y == 0) {
                System.out.println("x = " + x + ", y = " + y);
                break;
            }
        }
    }
}

在上述代码中,由于指令重排序的存在,a = 1x = bb = 1y = a的执行顺序可能会发生变化。理论上,xy不可能同时为0,但在实际运行中,由于指令重排序,可能会出现x = 0y = 0的情况。

深入理解volatile关键字

  1. volatile的可见性原理:当一个变量被声明为volatile时,JMM会在写操作时,在写操作后插入一个Store屏障指令,将工作内存中的变量值刷新到主内存中;在读操作时,在读操作前插入一个Load屏障指令,从主内存中读取最新的值到工作内存中。这样就保证了不同线程之间对volatile变量的可见性。

  2. volatile与指令重排序:volatile关键字不仅保证了可见性,还禁止了指令重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序。具体来说,JMM针对编译器制定了volatile重排序规则表,在这个规则表的约束下,编译器不会对volatile变量相关的指令进行重排序。

以下是一个使用volatile来解决可见性和有序性问题的示例:

public class VolatileExample {
    private volatile static int num = 0;

    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            num = 1;
            System.out.println("Thread set num to 1.");
        });

        thread.start();

        while (num == 0) {
            // 等待num变为1
        }
        System.out.println("Main thread sees num is 1.");
    }
}

在上述代码中,num被声明为volatile。当子线程将num设置为1后,主线程能够立即感知到这个变化,从而跳出循环并输出相应信息。

线程安全基础

线程安全是指当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。

  1. 不可变(Immutable):不可变对象天生就是线程安全的。一个不可变对象只要被正确地构建出来,其外部的可见状态永远也不会改变,永远也不会看到它处于不一致的状态。在Java中,创建不可变对象的关键是使用final关键字。例如,String类就是不可变的,它的所有字段都是final的,并且一旦创建后,其内容就不能被修改。

以下是一个自定义不可变类的示例:

public final class ImmutableClass {
    private final int value;

    public ImmutableClass(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}

在上述代码中,ImmutableClass类被声明为final,防止被继承。value字段被声明为final,一旦初始化后就不能被修改。这样的类是线程安全的,多个线程可以安全地访问它。

  1. 线程封闭(Thread Confinement):线程封闭是指将对象封闭在一个线程中,或者说一个对象只能由一个线程能访问。这样就不存在线程竞争问题,从而保证了线程安全。常见的线程封闭技术有:
    • 栈封闭:局部变量的固有属性之一就是封闭在线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。例如,以下代码展示了栈封闭:
public class StackConfinement {
    public void doWork() {
        int localVar = 0;
        // 只有当前线程能访问localVar
        localVar++;
    }
}
- **ThreadLocal**:`ThreadLocal`类提供了线程本地的变量。它为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其他线程所对应的副本。

以下是一个使用ThreadLocal的示例:

public class ThreadLocalExample {
    private static final ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                threadLocal.set(threadLocal.get() + 1);
                System.out.println("Thread1: " + threadLocal.get());
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                threadLocal.set(threadLocal.get() + 10);
                System.out.println("Thread2: " + threadLocal.get());
            }
        });

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,threadLocal为每个线程提供了独立的变量副本。线程1和线程2对threadLocal的操作不会相互影响。

同步容器与并发容器

  1. 同步容器:同步容器类包括Vector、Hashtable,以及Collections.synchronizedXxx()方法创建的容器。这些容器通过在方法上使用synchronized关键字来实现线程安全。例如,Vectoradd方法如下:
public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}

虽然同步容器提供了线程安全的保证,但是由于其所有方法都使用synchronized同步,在高并发环境下,性能可能会成为瓶颈。因为同一时间只有一个线程能访问容器的方法,其他线程需要等待锁的释放。

  1. 并发容器:Java 5.0 后引入了并发容器,如ConcurrentHashMapCopyOnWriteArrayList等。这些容器针对高并发环境进行了优化,提供了更好的性能和扩展性。

ConcurrentHashMap为例,它采用了分段锁的机制。在ConcurrentHashMap中,数据被分成多个段(Segment),每个段都有自己的锁。当一个线程访问某个段的数据时,只会锁住这个段,而不会影响其他段的访问。这样在高并发环境下,多个线程可以同时访问不同段的数据,提高了并发性能。

以下是一个使用ConcurrentHashMap的示例:

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        map.put("key1", 1);
        map.put("key2", 2);

        System.out.println(map.get("key1"));
    }
}

在上述代码中,ConcurrentHashMap可以在多线程环境下安全地使用,并且性能比传统的同步容器(如Hashtable)更好。

线程安全的设计模式

  1. 单例模式与线程安全:单例模式是一种常用的设计模式,它保证一个类只有一个实例,并提供一个全局访问点。在多线程环境下,实现单例模式需要考虑线程安全问题。

常见的线程安全单例模式实现有: - 饿汉式:饿汉式在类加载时就创建实例,天生是线程安全的。

public class EagerSingleton {
    private static final EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {}

    public static EagerSingleton getInstance() {
        return instance;
    }
}
- **双重检查锁定(DCL)**:双重检查锁定在第一次检查时,先判断实例是否已经存在,如果不存在再进入同步块创建实例。在同步块中再次检查实例是否存在,防止多个线程同时通过第一次检查。
public class DoubleCheckedLockingSingleton {
    private static volatile DoubleCheckedLockingSingleton instance;

    private DoubleCheckedLockingSingleton() {}

    public static DoubleCheckedLockingSingleton getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckedLockingSingleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}

在上述代码中,instance被声明为volatile,这是为了防止指令重排序。因为在创建instance时,可能会先分配内存空间,然后初始化对象,最后将instance指向分配的内存空间。如果没有volatile,可能会出现一个线程在instance还未初始化完成时就访问到了它,导致程序出错。

  1. 生产者 - 消费者模式与线程安全:生产者 - 消费者模式是一个经典的多线程设计模式,它通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯。

以下是一个简单的生产者 - 消费者模式的示例代码:

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class ProducerConsumerPattern {
    private static final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(10);

    static class Producer implements Runnable {
        @Override
        public void run() {
            try {
                for (int i = 0; i < 20; i++) {
                    queue.put(i);
                    System.out.println("Produced: " + i);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    static class Consumer implements Runnable {
        @Override
        public void run() {
            try {
                while (true) {
                    int num = queue.take();
                    System.out.println("Consumed: " + num);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        Thread producerThread = new Thread(new Producer());
        Thread consumerThread = new Thread(new Consumer());

        producerThread.start();
        consumerThread.start();

        try {
            producerThread.join();
            consumerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,BlockingQueue保证了线程安全。生产者将数据放入队列,消费者从队列中取出数据。当队列满时,生产者会被阻塞;当队列空时,消费者会被阻塞。这样就实现了生产者和消费者之间的协调,并且保证了线程安全。

总结与实践建议

在Java开发中,理解Java内存模型与线程安全是至关重要的。通过合理使用volatile关键字、同步机制、线程封闭技术以及并发容器等,可以有效地编写线程安全的代码。在实际开发中,需要根据具体的应用场景选择合适的线程安全策略。例如,对于只读的数据,可以使用不可变对象;对于高并发读写的场景,优先考虑并发容器。同时,要注意避免过度同步,以免影响性能。通过深入理解和实践这些知识,能够编写出高效、稳定且线程安全的Java程序。

希望通过本文对Java内存模型与线程安全的详细介绍,能帮助读者更好地掌握这方面的知识,并在实际编程中灵活运用。在多线程编程的领域中,不断实践和积累经验是提升技能的关键,同时要密切关注Java语言在这方面的发展和新特性,以适应不断变化的开发需求。