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

Java内存模型中的同步与互斥

2022-04-113.6k 阅读

Java内存模型基础

在深入探讨Java内存模型(JMM)中的同步与互斥之前,我们先来了解一下JMM的基础概念。Java内存模型是一种抽象的概念,它定义了Java程序中多线程之间如何访问共享变量以及如何同步这些访问的规则。

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

例如,假设有两个线程 ThreadAThreadB,它们都要访问共享变量 x

public class SharedVariableExample {
    private static int x = 0;

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

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

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

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

        System.out.println("最终的x值: " + x);
    }
}

在这个例子中,由于 ThreadAThreadBx 的操作是在各自的工作内存中进行,然后再写回主内存,这就可能导致竞态条件(Race Condition)。如果没有合适的同步机制,最终 x 的值可能不是我们预期的结果。

同步机制之synchronized关键字

原理

synchronized 关键字是Java提供的一种最基本的同步手段。它可以修饰方法或者代码块,用来保证在同一时刻,只有一个线程能够执行被 synchronized 修饰的代码。

当一个线程访问一个被 synchronized 修饰的方法或者代码块时,它会首先尝试获取对象的锁(Monitor)。如果锁可用,线程获取锁并执行代码。当代码执行完毕或者抛出异常时,线程释放锁,其他等待该锁的线程可以竞争获取锁。

修饰实例方法

synchronized 修饰实例方法时,锁是当前对象实例。

public class SynchronizedInstanceMethodExample {
    private int count = 0;

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

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        SynchronizedInstanceMethodExample example = new SynchronizedInstanceMethodExample();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

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

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

        System.out.println("最终的count值: " + example.getCount());
    }
}

在这个例子中,increment 方法被 synchronized 修饰,因此每次只有一个线程能够执行 increment 方法,从而避免了竞态条件,最终 count 的值会是2000。

修饰静态方法

synchronized 修饰静态方法时,锁是当前类的Class对象。因为静态方法属于类,而不是类的实例,所以锁针对的是整个类。

public class SynchronizedStaticMethodExample {
    private static int count = 0;

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

    public static int getCount() {
        return count;
    }

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

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                SynchronizedStaticMethodExample.increment();
            }
        });

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

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

        System.out.println("最终的count值: " + SynchronizedStaticMethodExample.getCount());
    }
}

在这个例子中,无论有多少个类的实例,increment 方法的同步都是基于类的Class对象锁,保证了多线程环境下 count 操作的原子性。

修饰代码块

synchronized 还可以修饰代码块,这种方式更加灵活,可以指定不同的锁对象。

public class SynchronizedBlockExample {
    private int count = 0;
    private final Object lock = new Object();

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

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        SynchronizedBlockExample example = new SynchronizedBlockExample();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

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

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

        System.out.println("最终的count值: " + example.getCount());
    }
}

在这个例子中,我们创建了一个 lock 对象,increment 方法中的 synchronized 代码块使用 lock 作为锁。这样,只有获取到 lock 锁的线程才能执行代码块中的 count++ 操作,从而保证了线程安全。

互斥与同步的关系

互斥是同步的一种特殊情况。互斥强调的是对共享资源的排他性访问,即同一时刻只有一个线程能够访问共享资源。而同步则更广泛,它不仅包括互斥,还包括线程之间的协作,例如线程之间的信号传递等。

在Java中,synchronized 关键字实现了互斥,同时也通过锁机制提供了一定程度的同步。例如,一个线程在获取锁并执行完 synchronized 代码块后,会将工作内存中的变量刷新回主内存,其他线程获取锁后会从主内存重新读取变量,这就保证了不同线程之间对共享变量的同步。

同步机制之volatile关键字

原理

volatile 关键字与 synchronized 不同,它主要用于保证变量的可见性。当一个变量被声明为 volatile 时,线程对该变量的修改会立即刷新到主内存,并且其他线程在读取该变量时会直接从主内存读取,而不是从自己的工作内存读取旧值。

示例

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

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

        Thread readerThread = new Thread(() -> {
            while (!flag) {
                // 等待flag变为true
            }
            System.out.println("Reader thread saw flag is true");
        });

        readerThread.start();
        writerThread.start();

        try {
            writerThread.join();
            readerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,如果 flag 没有被声明为 volatilereaderThread 可能会一直处于循环中,因为它可能一直在读取自己工作内存中的旧的 flag 值。而将 flag 声明为 volatile 后,writerThread 修改 flag 后会立即刷新到主内存,readerThread 会从主内存读取到最新的 flag 值,从而退出循环。

需要注意的是,volatile 并不能保证原子性。例如,对于 volatile int num = 0; num++; 这样的操作,由于 num++ 不是原子操作(它包含读取、加一、写回三个步骤),在多线程环境下仍然可能出现竞态条件。

同步机制之ReentrantLock

原理

ReentrantLock 是Java 5.0引入的一种可重入的互斥锁,它提供了比 synchronized 更灵活和强大的功能。ReentrantLock 实现了 Lock 接口,它的核心原理是基于AQS(AbstractQueuedSynchronizer)框架。

AQS是一个用于构建锁和同步器的框架,它通过一个FIFO队列来管理等待获取锁的线程。当一个线程尝试获取 ReentrantLock 时,如果锁可用,线程获取锁并将锁的持有计数加一;如果锁不可用,线程会被放入AQS队列中等待。当持有锁的线程释放锁时,它会将锁的持有计数减一,当计数为0时,锁被完全释放,AQS队列中的一个等待线程会被唤醒并尝试获取锁。

使用示例

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private static int count = 0;
    private static ReentrantLock lock = new ReentrantLock();

    public static void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public static int getCount() {
        return count;
    }

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

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                increment();
            }
        });

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

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

        System.out.println("最终的count值: " + getCount());
    }
}

在这个例子中,我们使用 ReentrantLock 来保护 count 的操作。lock.lock() 方法用于获取锁,lock.unlock() 方法用于释放锁。需要注意的是,unlock() 方法通常放在 finally 块中,以确保无论代码块中是否抛出异常,锁都会被正确释放。

与synchronized的比较

  1. 灵活性ReentrantLocksynchronized 更灵活。例如,ReentrantLock 支持公平锁和非公平锁(默认是非公平锁),而 synchronized 是非公平的。公平锁会按照线程等待的顺序来分配锁,而非公平锁则允许线程在锁可用时直接竞争锁,这样可能会导致某些线程长时间等待。
  2. 功能ReentrantLock 提供了更多的功能,如 tryLock() 方法可以尝试获取锁,如果获取不到锁不会一直等待,而是立即返回 falselockInterruptibly() 方法允许在等待锁的过程中响应中断。
  3. 性能:在竞争不激烈的情况下,synchronized 的性能与 ReentrantLock 相近。但在竞争激烈的情况下,ReentrantLock 的性能可能会更好,因为它可以使用非公平锁来减少线程切换的开销。

同步机制之Condition

原理

Condition 是与 Lock 配合使用的一个接口,它提供了更灵活的线程间协作方式。Condition 可以看作是传统 Object 类的 wait()notify()notifyAll() 方法的更强大替代品。

一个 Lock 对象可以创建多个 Condition 对象,每个 Condition 对象都有自己的等待队列。线程可以调用 Conditionawait() 方法进入等待状态,同时释放持有的锁。当其他线程调用该 Conditionsignal()signalAll() 方法时,等待队列中的一个或所有线程会被唤醒,并尝试重新获取锁。

使用示例

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionExample {
    private static Lock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();
    private static boolean ready = false;

    public static void main(String[] args) {
        Thread producerThread = new Thread(() -> {
            lock.lock();
            try {
                // 模拟生产过程
                Thread.sleep(1000);
                ready = true;
                System.out.println("Producer: Production completed");
                condition.signalAll();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        Thread consumerThread = new Thread(() -> {
            lock.lock();
            try {
                while (!ready) {
                    condition.await();
                }
                System.out.println("Consumer: Consuming data");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

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

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

在这个例子中,consumerThreadreadyfalse 时调用 condition.await() 进入等待状态并释放锁。producerThread 在生产完成后调用 condition.signalAll() 唤醒 consumerThreadconsumerThread 被唤醒后重新获取锁并继续执行。

死锁问题

死锁的定义与成因

死锁是指两个或多个线程相互等待对方释放资源,导致所有线程都无法继续执行的一种状态。死锁的形成通常需要满足以下四个条件:

  1. 互斥条件:资源不能被共享,只能被一个线程持有。
  2. 占有并等待条件:线程已经持有了一些资源,但又请求其他资源,并且在等待其他资源的过程中不会释放已持有的资源。
  3. 不可剥夺条件:线程持有的资源不能被其他线程强行剥夺,只能由持有资源的线程自己释放。
  4. 循环等待条件:存在一个线程环,其中每个线程都在等待下一个线程释放资源。

死锁示例

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("Thread 1: Holding lock 1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 1: Waiting for lock 2");
                synchronized (lock2) {
                    System.out.println("Thread 1: Holding lock 1 and lock 2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2: Holding lock 2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 2: Waiting for lock 1");
                synchronized (lock1) {
                    System.out.println("Thread 2: Holding lock 1 and lock 2");
                }
            }
        });

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

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

在这个例子中,thread1 先获取 lock1,然后尝试获取 lock2thread2 先获取 lock2,然后尝试获取 lock1。由于两个线程都不会主动释放已持有的锁,就会导致死锁。

死锁的预防与检测

  1. 预防死锁
    • 破坏互斥条件:尽量使用可共享的资源,避免独占资源。但有些资源本身就是独占的,如打印机,这种方法不太容易实现。
    • 破坏占有并等待条件:可以要求线程在启动时一次性获取所有需要的资源,或者在请求新资源时先释放已持有的资源。
    • 破坏不可剥夺条件:允许线程在一定条件下剥夺其他线程持有的资源。例如,当一个线程等待资源超时,可以强制它释放已持有的资源。
    • 破坏循环等待条件:可以对资源进行排序,线程按照一定的顺序获取资源,避免形成循环等待。
  2. 检测死锁:可以使用工具如 jstack 来检测Java程序中的死锁。jstack 命令可以打印出Java进程中所有线程的栈信息,通过分析栈信息可以发现死锁的线程。另外,一些高级的监控工具如VisualVM也可以帮助检测死锁。

并发容器与同步

ConcurrentHashMap

ConcurrentHashMap 是Java提供的线程安全的哈希表。它与 HashtablesynchronizedMap 不同,ConcurrentHashMap 采用了分段锁(Segment)的机制(在Java 8之后采用了CAS和synchronized相结合的方式),允许多个线程同时对不同的段进行操作,从而提高了并发性能。

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapExample {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                map.put("key" + i, i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 1000; i < 2000; i++) {
                map.put("key" + i, i);
            }
        });

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

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

        System.out.println("Map size: " + map.size());
    }
}

在这个例子中,ConcurrentHashMap 可以支持多个线程同时进行插入操作,而不需要对整个哈希表进行加锁,提高了并发性能。

CopyOnWriteArrayList

CopyOnWriteArrayList 是一个线程安全的 List 实现。它的核心思想是当对列表进行修改(如添加、删除元素)时,会创建一个原列表的副本,在副本上进行修改,然后将原列表的引用指向新的副本。而读操作则直接在原列表上进行,不需要加锁。

import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListExample {
    public static void main(String[] args) {
        CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();

        Thread writerThread = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                list.add("element" + i);
            }
        });

        Thread readerThread = new Thread(() -> {
            Iterator<String> iterator = list.iterator();
            while (iterator.hasNext()) {
                System.out.println(iterator.next());
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        writerThread.start();
        readerThread.start();

        try {
            writerThread.join();
            readerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,readerThread 可以在 writerThread 修改列表的同时进行读取操作,因为读操作是基于原列表的,而写操作创建新副本不会影响读操作。但需要注意的是,CopyOnWriteArrayList 适合读多写少的场景,因为写操作的开销较大。

通过对Java内存模型中的同步与互斥机制的深入探讨,我们了解了 synchronizedvolatileReentrantLockCondition 等同步工具的原理和使用方法,以及死锁的成因、预防和检测,还有并发容器在同步方面的特点。在实际的多线程编程中,我们需要根据具体的需求和场景选择合适的同步机制,以确保程序的正确性和高性能。