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

Java内存模型与并发编程详解

2021-11-281.1k 阅读

Java内存模型基础

在Java并发编程中,Java内存模型(Java Memory Model,JMM)起着关键作用。它定义了Java程序中各种变量(线程共享变量,如实例字段、静态字段和数组元素)的访问规则,以及在多线程环境下如何处理线程之间的可见性、原子性和有序性问题。

主内存与工作内存

Java内存模型将内存分为主内存(Main Memory)和工作内存(Working Memory)。所有的变量都存储在主内存中,而每个线程都有自己独立的工作内存。线程对变量的操作(读取、赋值等)必须在工作内存中进行,不能直接操作主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

例如,假设有两个线程 ThreadAThreadB 同时访问一个共享变量 sharedVariable

public class MemoryModelExample {
    private static int sharedVariable = 0;

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            // 线程A从主内存读取sharedVariable到工作内存
            int localValue = sharedVariable;
            localValue = localValue + 1;
            // 线程A将工作内存中的localValue写回主内存
            sharedVariable = localValue;
        });

        Thread threadB = new Thread(() -> {
            // 线程B从主内存读取sharedVariable到工作内存
            int localValue = sharedVariable;
            localValue = localValue * 2;
            // 线程B将工作内存中的localValue写回主内存
            sharedVariable = localValue;
        });

        threadA.start();
        threadB.start();
    }
}

在这个例子中,sharedVariable 存储在主内存中。ThreadAThreadB 分别将 sharedVariable 读取到自己的工作内存进行操作,然后再写回主内存。但由于线程执行顺序的不确定性,最终 sharedVariable 的值可能不是预期的结果。

可见性问题

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。由于线程操作变量是在工作内存中进行,当一个线程修改了工作内存中的变量后,如果没有及时将其写回主内存,其他线程就无法看到这个修改。

考虑以下代码:

public class VisibilityProblem {
    private static boolean flag = false;

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            while (!flag) {
                // 线程A等待flag为true
            }
            System.out.println("ThreadA stopped.");
        });

        Thread threadB = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("ThreadB set flag to true.");
        });

        threadA.start();
        threadB.start();
    }
}

在这个例子中,threadB 修改了 flag 的值,但 threadA 可能永远不会停止,因为 threadA 工作内存中的 flag 值没有及时更新。这就是典型的可见性问题。

原子性问题

原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中,对于简单的变量读取和赋值操作(如 int a = 10;),通常具有原子性。但对于复合操作,如 a++; (实际包含读取、加1、赋值三个步骤),则不具有原子性。

以下代码展示了原子性问题:

public class AtomicityProblem {
    private static int counter = 0;

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

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

        System.out.println("Expected counter value: 10000, actual value: " + counter);
    }
}

理论上,10 个线程每个线程执行 1000 次 counter++ 操作,最终 counter 的值应该是 10000。但由于 counter++ 不具有原子性,多个线程同时操作时会出现竞争,导致最终结果小于 10000。

有序性问题

有序性是指程序执行的顺序按照代码的先后顺序执行。然而,为了提高性能,编译器和处理器可能会对指令进行重排序。重排序可能会导致程序在单线程环境下运行正确,但在多线程环境下出现错误。

例如:

public class ReorderingProblem {
    private static int a = 0;
    private static int b = 0;

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            a = 1;
            b = 2;
        });

        Thread threadB = new Thread(() -> {
            if (b == 2) {
                System.out.println("a = " + a);
            }
        });

        threadA.start();
        threadB.start();
    }
}

在理想情况下,threadA 先执行 a = 1,再执行 b = 2threadB 输出 a = 1。但由于指令重排序,threadA 可能先执行 b = 2,此时 threadB 进入 if 语句,输出 a = 0,这与预期不符。

Java内存模型的规则与保障

为了解决上述可见性、原子性和有序性问题,Java内存模型制定了一系列规则和提供了相应的保障机制。

happens - before规则

happens - before规则定义了两个操作之间的偏序关系。如果一个操作happens - before另一个操作,那么第一个操作的执行结果对第二个操作是可见的,并且第一个操作按顺序排在第二个操作之前。

常见的happens - before规则有:

  1. 程序顺序规则:在一个线程内,按照程序代码顺序,书写在前面的操作happens - before书写在后面的操作。例如在 ThreadA 中,a = 1; happens - before b = 2;
  2. 监视器锁规则:对一个锁的解锁操作happens - before随后对这个锁的加锁操作。例如:
public class MonitorLockExample {
    private static final Object lock = new Object();
    private static int value = 0;

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (lock) {
                value = 10;
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Value: " + value);
            }
        });

        threadA.start();
        try {
            threadA.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadB.start();
    }
}

在这个例子中,threadAlock 解锁的操作happens - before threadBlock 加锁的操作,所以 threadB 能看到 threadAvalue 的修改。 3. volatile变量规则:对一个volatile变量的写操作happens - before随后对这个volatile变量的读操作。 4. 线程启动规则:Thread对象的start()方法happens - before此线程的每一个动作。 5. 线程终止规则:线程中的所有操作都happens - before对此线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。 6. 线程中断规则:对线程 interrupt() 方法的调用happens - before被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。 7. 传递性:如果操作A happens - before操作B,操作B happens - before操作C,那么操作A happens - before操作C。

volatile关键字

volatile 关键字可以保证变量的可见性和禁止指令重排序。当一个变量被声明为 volatile 时,对这个变量的写操作会立即刷新到主内存,而读操作会直接从主内存读取,从而确保其他线程能立即看到最新的值。

public class VolatileExample {
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            while (!flag) {
                // 线程A等待flag为true
            }
            System.out.println("ThreadA stopped.");
        });

        Thread threadB = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("ThreadB set flag to true.");
        });

        threadA.start();
        threadB.start();
    }
}

在这个例子中,将 flag 声明为 volatile 后,threadB 修改 flag 的值能立即被 threadA 看到,threadA 会停止循环。

volatile 禁止指令重排序是通过内存屏障(Memory Barrier)实现的。在 volatile 写操作前插入StoreStore屏障,确保前面的普通写操作先于 volatile 写操作;在 volatile 写操作后插入StoreLoad屏障,确保 volatile 写操作先于后续的读操作。在 volatile 读操作前插入LoadLoad和LoadStore屏障,确保 volatile 读操作先于后续的普通读写操作。

synchronized关键字

synchronized 关键字用于实现线程同步,它可以保证在同一时刻只有一个线程能够进入同步块或同步方法,从而解决原子性问题。同时,它也能保证可见性,因为在进入同步块或方法时会从主内存读取变量,退出时会将变量写回主内存。

public class SynchronizedExample {
    private static int counter = 0;

    public static synchronized void increment() {
        counter++;
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; 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("Expected counter value: 10000, actual value: " + counter);
    }
}

在这个例子中,通过将 increment 方法声明为 synchronized,保证了 counter++ 操作的原子性,最终 counter 的值会是 10000。

synchronized 是通过对象头中的Monitor锁来实现同步的。当线程进入同步块或方法时,会尝试获取Monitor锁,获取成功则进入,否则阻塞等待。

final关键字

final 关键字修饰的变量一旦初始化后就不可改变。对于 final 修饰的对象引用,虽然引用本身不可变,但对象内部的状态是可以改变的。

在多线程环境下,final 关键字也能提供一定的内存可见性保障。当一个对象被正确构造(即构造函数没有溢出,没有在构造函数中提前暴露 this 引用),那么其他线程可以看到 final 字段在构造函数中被初始化的值。

public class FinalExample {
    private final int value;

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

    public int getValue() {
        return value;
    }

    public static void main(String[] args) {
        FinalExample example = new FinalExample(10);
        Thread thread = new Thread(() -> {
            System.out.println("Value: " + example.getValue());
        });
        thread.start();
    }
}

在这个例子中,value 被声明为 final,在对象构造完成后,其他线程能正确获取到 value 的值。

Java并发编程中的高级特性

在理解了Java内存模型的基础上,我们可以进一步探讨Java并发编程中的一些高级特性,如线程安全的集合类、并发工具类等。

线程安全的集合类

  1. ConcurrentHashMapConcurrentHashMap 是线程安全的哈希表。它采用分段锁的机制,允许多个线程同时对不同的段进行操作,从而提高并发性能。
import java.util.concurrent.ConcurrentHashMap;

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

        Thread threadB = new Thread(() -> {
            Integer value = map.get("key1");
            System.out.println("Value from threadB: " + value);
        });

        threadA.start();
        try {
            threadA.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadB.start();
    }
}

在这个例子中,ConcurrentHashMap 保证了多线程操作的线程安全性。 2. CopyOnWriteArrayListCopyOnWriteArrayList 是线程安全的列表。它的特点是在进行写操作(如添加、修改元素)时,会复制一份原数组,在新数组上进行操作,操作完成后再将新数组赋值给原数组。读操作则直接读取原数组,因此读操作是线程安全的,且不需要加锁,性能较高。

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListExample {
    public static void main(String[] args) {
        List<String> list = new CopyOnWriteArrayList<>();
        Thread threadA = new Thread(() -> {
            list.add("element1");
        });

        Thread threadB = new Thread(() -> {
            String element = list.get(0);
            System.out.println("Element from threadB: " + element);
        });

        threadA.start();
        try {
            threadA.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadB.start();
    }
}

这个例子展示了 CopyOnWriteArrayList 在多线程环境下的使用。

并发工具类

  1. CountDownLatchCountDownLatch 可以让一个或多个线程等待其他线程完成一组操作后再继续执行。它通过一个计数器来实现,当计数器的值为 0 时,等待的线程被释放。
import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) {
        int numThreads = 5;
        CountDownLatch latch = new CountDownLatch(numThreads);

        for (int i = 0; i < numThreads; i++) {
            new Thread(() -> {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + " has finished.");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    latch.countDown();
                }
            }).start();
        }

        try {
            latch.await();
            System.out.println("All threads have finished.");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,主线程通过 latch.await() 等待所有子线程完成任务,子线程完成任务后调用 latch.countDown() 减少计数器的值。 2. CyclicBarrierCyclicBarrier 可以让一组线程相互等待,直到所有线程都到达某个屏障点,然后再一起继续执行。它与 CountDownLatch 的区别在于 CyclicBarrier 可以重用,而 CountDownLatch 只能使用一次。

import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    public static void main(String[] args) {
        int numThreads = 3;
        CyclicBarrier barrier = new CyclicBarrier(numThreads, () -> {
            System.out.println("All threads have reached the barrier.");
        });

        for (int i = 0; i < numThreads; i++) {
            new Thread(() -> {
                try {
                    Thread.sleep((long) (Math.random() * 2000));
                    System.out.println(Thread.currentThread().getName() + " is waiting at the barrier.");
                    barrier.await();
                    System.out.println(Thread.currentThread().getName() + " has passed the barrier.");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

在这个例子中,三个线程分别等待,当所有线程都调用 barrier.await() 后,会执行 CyclicBarrier 的构造函数中传入的 Runnable 任务,然后所有线程继续执行后续代码。 3. SemaphoreSemaphore 是一个计数信号量,它通过一个计数器来控制同时访问某个资源的线程数量。当计数器大于 0 时,线程可以获取信号量(计数器减 1)并访问资源,当计数器为 0 时,线程需要等待,直到有其他线程释放信号量(计数器加 1)。

import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    public static void main(String[] args) {
        int availablePermits = 2;
        Semaphore semaphore = new Semaphore(availablePermits);

        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + " has acquired the semaphore.");
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + " is releasing the semaphore.");
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

在这个例子中,Semaphore 初始有 2 个许可,最多允许 2 个线程同时获取许可并访问资源。

Java并发编程中的性能优化与注意事项

在进行Java并发编程时,除了保证程序的正确性,还需要关注性能优化和一些常见的注意事项。

性能优化

  1. 减少锁的粒度:尽量缩小锁的作用范围,避免不必要的同步。例如,在 ConcurrentHashMap 中采用分段锁,每个段有自己的锁,不同段的操作可以并发进行,相比对整个哈希表加锁,大大提高了并发性能。
  2. 使用无锁数据结构:对于一些读多写少的场景,可以考虑使用无锁数据结构,如 ConcurrentSkipListMapConcurrentSkipListSet 等。这些数据结构通过乐观锁的机制,避免了传统锁带来的线程阻塞和上下文切换开销。
  3. 线程池的合理使用:合理配置线程池的参数,如核心线程数、最大线程数、队列容量等,可以提高线程的复用率,减少线程创建和销毁的开销。例如,对于 CPU 密集型任务,线程池的核心线程数可以设置为 CPU 核心数;对于 I/O 密集型任务,可以适当增加核心线程数,以充分利用 CPU 资源。

注意事项

  1. 死锁问题:死锁是指两个或多个线程相互等待对方释放资源,导致程序无法继续执行的情况。要避免死锁,需要遵循一些原则,如按相同顺序获取锁、避免嵌套锁、设置合理的锁超时时间等。
public class DeadlockExample {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (lockA) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockB) {
                    System.out.println("ThreadA has acquired both locks.");
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (lockB) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lockA) {
                    System.out.println("ThreadB has acquired both locks.");
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在这个例子中,threadAthreadB 相互等待对方释放锁,导致死锁。可以通过按相同顺序获取锁来避免死锁,如都先获取 lockA,再获取 lockB。 2. 资源竞争与饥饿:当多个线程竞争有限的资源时,可能会出现部分线程长时间无法获取资源的情况,即线程饥饿。可以通过公平调度算法、合理分配资源等方式来避免线程饥饿。 3. 线程安全与可维护性:在编写并发程序时,要确保代码的线程安全性,同时也要考虑代码的可维护性。过度使用同步可能会导致性能下降,而不恰当的同步可能会导致线程安全问题。因此,需要在保证线程安全的前提下,尽量简化代码结构,提高代码的可读性和可维护性。

通过深入理解Java内存模型和掌握并发编程的高级特性、性能优化及注意事项,开发者可以编写出高效、可靠的多线程Java程序,充分利用多核处理器的性能优势,提升应用程序的整体性能。