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

Java内存模型与线程安全

2021-12-273.5k 阅读

Java内存模型

什么是Java内存模型

Java内存模型(Java Memory Model,JMM)是一种抽象的概念,它定义了Java程序中多线程之间如何访问共享变量的规则。JMM的主要目标是确保在多线程环境下,程序的行为是可预测的,并且能够正确地处理内存可见性和线程安全问题。

在Java中,所有的共享变量都存储在主内存(Main Memory)中。每个线程都有自己的工作内存(Working Memory),工作内存中保存了该线程使用到的共享变量的副本。当线程访问共享变量时,它首先从主内存中将变量的值复制到自己的工作内存中,然后在工作内存中进行操作。当线程修改了共享变量的值后,并不会立即将其写回到主内存中,而是在某个时刻将修改后的值刷新回主内存。

Java内存模型的特性

  1. 原子性(Atomicity)
    • 原子性是指一个操作是不可中断的,要么全部执行成功,要么全部执行失败。在Java中,对于基本数据类型(除了 long 和 double)的变量赋值操作,以及使用 synchronized 关键字修饰的代码块,都是具有原子性的。
    • 例如,以下代码中 count++ 操作不是原子性的:
public class AtomicityTest {
    private static int count = 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++) {
                    count++;
                }
            });
            threads[i].start();
        }
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("Final count: " + count);
    }
}
  • 上述代码创建了10个线程,每个线程对 count 变量进行1000次自增操作。理想情况下,最终 count 的值应该是10000,但由于 count++ 不是原子操作,实际结果往往小于10000。这是因为 count++ 操作可以分解为读取 count 的值、增加1、再写回 count 的值这三个步骤,在多线程环境下可能会出现数据竞争。
  • 如果要保证 count++ 操作的原子性,可以使用 AtomicInteger 类,它提供了原子性的自增操作:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicityWithAtomicInteger {
    private static AtomicInteger count = new AtomicInteger(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++) {
                    count.incrementAndGet();
                }
            });
            threads[i].start();
        }
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("Final count: " + count.get());
    }
}
  1. 可见性(Visibility)
    • 可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在Java中,由于线程工作内存和主内存的存在,共享变量的修改在没有特殊处理的情况下,其他线程可能不会立即看到。
    • 例如,以下代码中 flag 变量的修改可能不会被其他线程立即看到:
public class VisibilityTest {
    private static boolean flag = false;
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("Thread1 set flag to true");
        });
        Thread thread2 = new Thread(() -> {
            while (!flag) {
                // Do nothing, just wait for flag to be true
            }
            System.out.println("Thread2 sees flag is true");
        });
        thread1.start();
        thread2.start();
    }
}
  • 在上述代码中,thread1 线程将 flag 设置为 true,但 thread2 线程可能会一直处于循环等待状态,因为 flag 的修改没有及时刷新到主内存,thread2 线程的工作内存中的 flag 副本还是 false
  • 要解决可见性问题,可以使用 volatile 关键字修饰共享变量。volatile 关键字可以保证变量的修改对其他线程是立即可见的:
public class VisibilityWithVolatile {
    private static volatile boolean flag = false;
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("Thread1 set flag to true");
        });
        Thread thread2 = new Thread(() -> {
            while (!flag) {
                // Do nothing, just wait for flag to be true
            }
            System.out.println("Thread2 sees flag is true");
        });
        thread1.start();
        thread2.start();
    }
}
  1. 有序性(Ordering)
    • 有序性是指程序执行的顺序按照代码的先后顺序执行。然而,在Java中,为了提高程序的执行效率,编译器和处理器可能会对指令进行重排序。重排序可能会导致程序在多线程环境下出现意外的结果。
    • 例如,以下代码可能会因为指令重排序而出现问题:
public class OrderingTest {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            a = 1;
            b = 2;
        });
        Thread thread2 = new Thread(() -> {
            if (b == 2) {
                System.out.println("a is " + a);
            }
        });
        thread1.start();
        thread2.start();
    }
}
  • 在理想情况下,当 thread2 线程进入 if 语句时,a 应该已经被 thread1 线程设置为1。但由于指令重排序,thread1 线程可能先执行 b = 2,然后再执行 a = 1。这样当 thread2 线程进入 if 语句时,b 为2,但 a 可能还没有被设置为1,从而输出 a is 0
  • volatile 关键字除了保证可见性,还能禁止指令重排序。另外,synchronized 关键字也能保证有序性,因为 synchronized 块中的代码在同一时刻只能被一个线程执行,从而避免了指令重排序带来的问题。

线程安全

线程安全的定义

线程安全是指当多个线程访问某个类、对象或者方法时,这个类、对象或者方法能够表现出正确的行为,不会因为多线程的并发访问而导致数据不一致或者程序出现错误。一个线程安全的类或者方法应该能够保证在多线程环境下,对其共享状态的访问和修改是正确的。

线程安全的实现方式

  1. 不可变对象(Immutable Objects)
    • 不可变对象是指一旦创建,其状态就不能被修改的对象。在Java中,String 类就是一个典型的不可变对象。不可变对象天生就是线程安全的,因为多个线程无法修改其状态,也就不存在数据竞争的问题。
    • 例如,以下自定义一个不可变对象 Point
public final class Point {
    private final int x;
    private final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    public int getX() {
        return x;
    }
    public int getY() {
        return y;
    }
}
  • 在上述代码中,Point 类被声明为 final,防止被继承。xy 字段被声明为 final,保证一旦初始化后就不能被修改。这样,多个线程可以安全地共享 Point 对象。
  1. 互斥同步(Mutex Synchronization)
    • 互斥同步是最常用的一种线程安全实现方式,它通过同步机制保证同一时刻只有一个线程能够访问共享资源。在Java中,主要通过 synchronized 关键字和 ReentrantLock 类来实现互斥同步。
    • synchronized 关键字
      • synchronized 关键字可以修饰方法或者代码块。当修饰方法时,整个方法体都被同步,例如:
public class SynchronizedMethodExample {
    private int count = 0;
    public synchronized void increment() {
        count++;
    }
    public synchronized int getCount() {
        return count;
    }
}
 - 在上述代码中,`increment` 方法和 `getCount` 方法都被 `synchronized` 修饰,保证了在多线程环境下,对 `count` 变量的访问和修改是线程安全的。当一个线程进入 `increment` 方法时,其他线程就不能同时进入 `increment` 方法或者 `getCount` 方法。
 - `synchronized` 关键字也可以修饰代码块,这样可以更细粒度地控制同步范围:
public class SynchronizedBlockExample {
    private int count = 0;
    private final Object lock = new Object();
    public void increment() {
        synchronized (lock) {
            count++;
        }
    }
    public int getCount() {
        synchronized (lock) {
            return count;
        }
    }
}
 - 在上述代码中,`increment` 方法和 `getCount` 方法通过 `synchronized` 块同步,锁对象是 `lock`。这样,只有获取到 `lock` 对象锁的线程才能进入相应的 `synchronized` 块,从而保证了对 `count` 变量的线程安全访问。
  • ReentrantLock
    • ReentrantLock 类提供了与 synchronized 关键字类似的功能,但它更加灵活。ReentrantLock 支持公平锁和非公平锁,默认是非公平锁。非公平锁在获取锁时,允许新的线程插队,而公平锁则按照线程请求的顺序分配锁。
    • 例如,以下代码使用 ReentrantLock 实现线程安全的计数器:
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}
 - 在上述代码中,`increment` 方法和 `getCount` 方法通过 `lock.lock()` 获取锁,在操作完成后通过 `lock.unlock()` 释放锁。使用 `try - finally` 块保证即使在操作过程中抛出异常,锁也能被正确释放。

3. 非阻塞同步(Non - blocking Synchronization)

  • 非阻塞同步是一种不使用锁的同步方式,它通过硬件提供的原子操作(如 compare - and - swap,简称 CAS)来实现线程安全。在Java中,java.util.concurrent.atomic 包下的类就是基于非阻塞同步实现的。
  • 例如,AtomicInteger 类的 incrementAndGet 方法就是基于 CAS 操作实现的:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicIntegerExample {
    private static AtomicInteger count = new AtomicInteger(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++) {
                    count.incrementAndGet();
                }
            });
            threads[i].start();
        }
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("Final count: " + count.get());
    }
}
  • AtomicInteger 类内部使用了 Unsafe 类提供的 CAS 操作来实现原子性的自增操作。CAS 操作包含三个参数:内存位置、预期值和新值。它会比较内存位置的值与预期值,如果相等则将内存位置的值更新为新值,否则不做任何操作,并返回操作是否成功。这种方式避免了使用锁带来的线程阻塞和上下文切换开销,在高并发环境下具有更好的性能。

线程安全的注意事项

  1. 避免死锁(Deadlock Avoidance)
    • 死锁是指两个或多个线程相互等待对方释放资源,从而导致所有线程都无法继续执行的情况。在使用同步机制时,要特别注意避免死锁。
    • 例如,以下代码可能会导致死锁:
public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread1 acquired lock1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread1 acquired lock2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread2 acquired lock2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("Thread2 acquired lock1");
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}
  • 在上述代码中,thread1 先获取 lock1,然后尝试获取 lock2,而 thread2 先获取 lock2,然后尝试获取 lock1。如果 thread1 先获取了 lock1thread2 先获取了 lock2,那么两个线程就会相互等待对方释放锁,从而导致死锁。
  • 为了避免死锁,可以遵循以下原则:
    • 按顺序获取锁:所有线程按照相同的顺序获取锁,例如都先获取 lock1,再获取 lock2
    • 限时获取锁:使用 tryLock 方法限时获取锁,如果在规定时间内无法获取到锁,则放弃并进行其他操作。
  1. 锁的粒度(Lock Granularity)
    • 锁的粒度是指锁所保护的代码块的大小。锁的粒度过大,会导致多个线程竞争锁的概率增加,从而降低并发性能;锁的粒度过小,又可能会因为频繁地获取和释放锁而增加开销。
    • 例如,以下代码中锁的粒度过大:
public class LargeLockGranularityExample {
    private int[] data = new int[10000];
    private final Object lock = new Object();
    public void updateData(int index, int value) {
        synchronized (lock) {
            data[index] = value;
        }
    }
    public int getData(int index) {
        synchronized (lock) {
            return data[index];
        }
    }
}
  • 在上述代码中,整个 updateDatagetData 方法都被锁保护,即使不同线程操作的是数组的不同位置,也会因为竞争锁而降低并发性能。可以通过减小锁的粒度来提高并发性能,例如:
public class SmallLockGranularityExample {
    private int[] data = new int[10000];
    private final Object[] locks = new Object[data.length];
    public SmallLockGranularityExample() {
        for (int i = 0; i < locks.length; i++) {
            locks[i] = new Object();
        }
    }
    public void updateData(int index, int value) {
        synchronized (locks[index]) {
            data[index] = value;
        }
    }
    public int getData(int index) {
        synchronized (locks[index]) {
            return data[index];
        }
    }
}
  • 在上述代码中,为数组的每个元素都分配了一个锁,不同线程操作不同元素时不会竞争同一个锁,从而提高了并发性能。
  1. 线程本地存储(Thread - Local Storage)
    • 线程本地存储是一种让每个线程都有自己独立的变量副本的机制。在Java中,可以使用 ThreadLocal 类来实现线程本地存储。
    • 例如,以下代码使用 ThreadLocal 为每个线程提供独立的计数器:
public class ThreadLocalExample {
    private static ThreadLocal<Integer> threadLocalCount = ThreadLocal.withInitial(() -> 0);
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                threadLocalCount.set(threadLocalCount.get() + 1);
            }
            System.out.println("Thread1 count: " + threadLocalCount.get());
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 2000; i++) {
                threadLocalCount.set(threadLocalCount.get() + 1);
            }
            System.out.println("Thread2 count: " + threadLocalCount.get());
        });
        thread1.start();
        thread2.start();
    }
}
  • 在上述代码中,threadLocalCount 是一个 ThreadLocal 对象,每个线程都有自己独立的 threadLocalCount 副本。线程1和线程2对 threadLocalCount 的操作不会相互影响,从而保证了线程安全。

通过深入理解Java内存模型和掌握线程安全的实现方式,开发人员可以编写出高效、可靠的多线程Java程序。在实际应用中,需要根据具体的业务需求和性能要求,选择合适的线程安全策略。