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

Java内存分配的线程安全性

2022-04-016.7k 阅读

Java内存模型基础

在深入探讨Java内存分配的线程安全性之前,我们先来了解一下Java内存模型(Java Memory Model,JMM)的基础知识。JMM是Java虚拟机规范中定义的一种抽象的内存模型,它描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。

主内存与工作内存

JMM规定所有的变量都存储在主内存(Main Memory)中,主内存是所有线程共享的。而每个线程都有自己独立的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量在主内存中的副本拷贝。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

例如,假设有一个共享变量count存储在主内存中,线程A和线程B都需要操作这个变量。线程A会将count的值从主内存复制到自己的工作内存中,在工作内存中对其进行操作(比如增加1),然后再将修改后的值写回到主内存。同样,线程B也会经历类似的过程。如果没有合适的同步机制,就可能出现线程安全问题。

内存操作原子性、可见性与有序性

  1. 原子性(Atomicity):原子操作是指不可被中断的操作,在执行过程中不会被其他线程干扰。在Java中,对于基本数据类型的变量(除了longdouble)的简单读写操作是原子性的。例如:
int a = 10; // 原子操作
a++; // 非原子操作,实际包含读取、增加、写入三个步骤

而对于longdouble类型,虽然在JVM规范中没有明确规定它们的读写操作一定是原子性的,但在大多数实际实现中,它们的读写操作也是原子性的。不过为了确保原子性,在多线程环境下操作这些类型变量时,最好还是使用同步机制。

  1. 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。由于线程对变量的操作是在工作内存中进行的,所以如果没有特殊的机制,一个线程对共享变量的修改不会立即反映到主内存中,其他线程也就无法及时看到这个变化。例如:
public class VisibilityProblem {
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            while (!flag) {
                // 线程1循环等待flag变为true
            }
            System.out.println("线程1结束");
        }).start();

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

        flag = true; // 主线程修改flag
        System.out.println("主线程修改了flag");
    }
}

在上述代码中,理论上主线程修改了flagtrue后,线程1应该能跳出循环。但实际上,由于线程1工作内存中的flag副本可能没有及时更新,它可能会一直循环下去,这就是可见性问题。

  1. 有序性(Ordering):有序性是指程序执行的顺序按照代码的先后顺序执行。然而,为了提高性能,编译器和处理器可能会对指令进行重排序。重排序可能会导致程序执行顺序与代码顺序不一致,在单线程环境下,重排序不会影响程序的最终结果,但在多线程环境下,可能会引发线程安全问题。例如:
public class ReorderingProblem {
    private static int a = 0;
    private static int b = 0;

    public static void main(String[] args) {
        new Thread(() -> {
            a = 1; // 语句1
            b = a; // 语句2
        }).start();

        new Thread(() -> {
            if (b != 0) {
                System.out.println("b = " + b + ", a = " + a);
            }
        }).start();
    }
}

按照代码顺序,语句2应该在语句1执行之后,所以如果b != 0,那么a应该为1。但由于指令重排序,可能语句2会在语句1之前执行,这样就可能出现b != 0a为0的情况。

Java内存分配方式

在Java中,内存分配主要涉及以下几种方式,不同的分配方式在多线程环境下有着不同的线程安全特性。

栈内存分配

局部变量通常在栈内存上分配。当一个方法被调用时,会在栈上为该方法创建一个栈帧,方法中的局部变量就存储在这个栈帧中。栈内存是线程私有的,每个线程都有自己独立的栈,所以栈内存分配不存在线程安全问题。例如:

public class StackAllocationExample {
    public void method() {
        int localVar = 10; // localVar在栈上分配
        // 对localVar的操作只在当前线程的栈帧内进行,不会有线程安全问题
    }
}

堆内存分配

对象和数组等引用类型的数据通常在堆内存上分配。堆内存是所有线程共享的,多个线程可能同时访问堆中的对象,这就可能引发线程安全问题。例如:

public class HeapAllocationExample {
    private static class SharedObject {
        int value;
    }

    public static void main(String[] args) {
        SharedObject shared = new SharedObject();
        new Thread(() -> {
            shared.value = 10;
        }).start();

        new Thread(() -> {
            System.out.println("Value: " + shared.value);
        }).start();
    }
}

在上述代码中,SharedObject对象在堆上分配,两个线程同时访问shared对象的value属性,可能会出现线程安全问题,比如第二个线程打印出的值可能不是10,因为第一个线程的修改可能还没有同步到主内存,第二个线程读取的是旧值。

方法区内存分配

方法区主要存储类信息、常量、静态变量等。其中静态变量在方法区分配,它是类级别的变量,被所有线程共享,同样可能存在线程安全问题。例如:

public class MethodAreaAllocationExample {
    private static int staticVar = 0;

    public static void main(String[] args) {
        new Thread(() -> {
            staticVar++;
        }).start();

        new Thread(() -> {
            System.out.println("Static Var: " + staticVar);
        }).start();
    }
}

这里的staticVar在方法区分配,两个线程同时对其进行操作,可能会出现数据不一致的情况。

线程安全问题示例

竞争条件(Race Condition)

竞争条件是多线程编程中最常见的线程安全问题之一,它发生在多个线程同时访问和修改共享资源时,最终的结果取决于线程执行的相对顺序。例如,实现一个简单的计数器:

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

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

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

        System.out.println("Expected count: 1000000, Actual count: " + counter.getCount());
    }
}

在上述代码中,我们期望计数器counter最终的值为1000 * 1000 = 1000000,但由于count++操作不是原子性的,多个线程同时执行increment方法时会发生竞争条件,导致最终的计数结果小于1000000。

死锁(Deadlock)

死锁是指两个或多个线程相互等待对方释放资源,而导致所有线程都无法继续执行的情况。例如:

public class DeadlockExample {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1 locked resource1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1 locked resource2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread 2 locked resource2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource1) {
                    System.out.println("Thread 2 locked resource1");
                }
            }
        });

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

在上述代码中,thread1先获取resource1的锁,然后尝试获取resource2的锁;thread2先获取resource2的锁,然后尝试获取resource1的锁。如果thread1获取resource1锁后,thread2获取resource2锁,此时两个线程都在等待对方释放锁,就会发生死锁。

保证Java内存分配线程安全性的机制

同步关键字(synchronized)

synchronized关键字可以用来保证在同一时刻,只有一个线程能够访问被同步的代码块或方法,从而避免竞争条件。它可以修饰方法或代码块。

  1. 修饰实例方法:当synchronized修饰实例方法时,锁对象是当前实例对象this。例如,修改前面的Counter类:
public class SynchronizedCounter {
    private int count = 0;

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

    public synchronized int getCount() {
        return count;
    }
}
public class SynchronizedCounterExample {
    public static void main(String[] args) {
        SynchronizedCounter counter = new SynchronizedCounter();
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

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

        System.out.println("Expected count: 1000000, Actual count: " + counter.getCount());
    }
}

在上述代码中,incrementgetCount方法都被synchronized修饰,保证了同一时刻只有一个线程能访问这些方法,从而避免了竞争条件,最终的计数结果会是1000000。

  1. 修饰静态方法:当synchronized修饰静态方法时,锁对象是当前类的Class对象。例如:
public class StaticSynchronizedCounter {
    private static int count = 0;

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

    public static synchronized int getCount() {
        return count;
    }
}
  1. 修饰代码块synchronized还可以修饰代码块,指定锁对象。例如:
public class BlockSynchronizedCounter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }

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

在上述代码中,我们使用了一个自定义的lock对象作为锁,同样可以保证线程安全。

重入锁(ReentrantLock)

ReentrantLock是Java 5.0引入的一种可重入的互斥锁,它提供了比synchronized更灵活的锁控制。例如:

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockCounter {
    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();
        }
    }
}
public class ReentrantLockCounterExample {
    public static void main(String[] args) {
        ReentrantLockCounter counter = new ReentrantLockCounter();
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

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

        System.out.println("Expected count: 1000000, Actual count: " + counter.getCount());
    }
}

在上述代码中,ReentrantLock通过lock方法获取锁,通过unlock方法释放锁。使用try - finally块确保在任何情况下锁都能被正确释放,从而保证线程安全。与synchronized相比,ReentrantLock提供了更多的功能,如可中断的锁获取、公平锁等。

原子类(Atomic Classes)

Java的java.util.concurrent.atomic包提供了一系列原子类,这些类的操作都是原子性的,无需额外的同步机制就能保证线程安全。例如,AtomicInteger类:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}
public class AtomicCounterExample {
    public static void main(String[] args) {
        AtomicCounter counter = new AtomicCounter();
        Thread[] threads = new Thread[1000];
        for (int i = 0; i < 1000; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

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

        System.out.println("Expected count: 1000000, Actual count: " + counter.getCount());
    }
}

在上述代码中,AtomicIntegerincrementAndGet方法是原子性的,保证了多线程环境下的线程安全,最终的计数结果也会是1000000。原子类通过硬件级别的指令(如CAS,Compare - And - Swap)来实现原子操作,性能通常比synchronized更好。

线程局部变量(ThreadLocal)

ThreadLocal类提供了线程局部变量的功能,每个线程都有自己独立的变量副本,互不干扰,从而避免了线程安全问题。例如:

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

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

        Thread thread2 = new Thread(() -> {
            int value = threadLocal.get();
            value += 2;
            threadLocal.set(value);
            System.out.println("Thread 2 value: " + threadLocal.get());
        });

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

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

在上述代码中,threadLocal为每个线程提供了独立的Integer变量副本。线程1和线程2对threadLocal中的值进行操作时,不会相互影响,各自的操作都是基于自己的副本,从而保证了线程安全。

总结不同机制的适用场景

  1. synchronized:适用于简单的同步场景,代码简洁,使用方便。当同步代码块或方法的执行时间较短,且没有复杂的锁控制需求时,使用synchronized是一个不错的选择。例如,对一些简单的计数器操作、资源访问控制等。

  2. ReentrantLock:适用于需要更灵活锁控制的场景,如可中断的锁获取、公平锁、锁的轮询等。当同步代码块执行时间较长,或者需要更精细的锁管理时,ReentrantLock能提供更好的性能和功能。例如,在一些高并发的缓存系统中,可能需要使用公平锁来保证每个线程都有机会获取锁。

  3. 原子类:适用于对单个变量进行原子操作的场景,尤其是在性能要求较高的情况下。例如,在统计系统中的计数器,如果只需要对一个变量进行简单的原子性增减操作,使用原子类可以避免使用锁带来的性能开销。

  4. ThreadLocal:适用于每个线程需要独立的变量副本,且变量之间不需要共享数据的场景。例如,在数据库连接管理中,每个线程可能需要有自己独立的数据库连接,使用ThreadLocal可以方便地实现这一点,避免线程之间对数据库连接的竞争。

通过深入理解Java内存分配的线程安全性相关知识,我们能够在编写多线程程序时,选择合适的同步机制来确保程序的正确性和性能。不同的机制各有优缺点,在实际应用中需要根据具体的场景进行合理选择。