线程同步机制:`synchronized` 关键字与锁对象

线程同步机制:synchronized 关键字与锁对象

大家好,欢迎来到我的线程同步世界!今天咱们要聊聊Java并发编程中的一位老朋友,也是一位核心人物——synchronized 关键字。它就像一位沉默的守护者,默默地保护着我们的共享数据,防止多线程环境下出现混乱,让我们一起揭开它的神秘面纱。

1. 为什么需要线程同步?

想象一下这样的场景:你和你的小伙伴同时操作银行账户。你准备取钱,他准备存钱。如果没有人协调,你们可能同时读到账户余额,然后分别计算新的余额,最终导致账户余额出错。这就是并发问题,也就是多个线程同时访问和修改共享数据时可能出现的问题。

更具体一点,想想以下的代码:

public class Counter {
    private int count = 0;

    public void increment() {
        count++; // 这不是原子操作!
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join(); // 等待t1线程执行完毕
        t2.join(); // 等待t2线程执行完毕

        System.out.println("最终计数: " + counter.getCount()); // 结果可能不是20000
    }
}

这段代码看似简单,两个线程各自增加计数器 10000 次,期望最终结果是 20000。然而,实际运行结果往往小于 20000。这是因为 count++ 并非原子操作,它实际上包含了三个步骤:

  1. 读取 count 的值。
  2. count 的值加 1
  3. 将新的值写回 count

在多线程环境下,这三个步骤可能被其他线程打断,导致数据不一致。例如:

  1. 线程 1 读取 count 的值为 5
  2. 线程 2 读取 count 的值为 5
  3. 线程 1 将 count 的值加 1,得到 6,并写回 count
  4. 线程 2 将 count 的值加 1,得到 6,并写回 count

结果是,count 只增加了 1,而不是 2

为了解决这类问题,我们需要线程同步机制,保证对共享数据的操作是原子性的,防止数据竞争。而 synchronized 关键字就是Java提供的最基本的同步机制之一。

2. synchronized 关键字:一位忠实的守护者

synchronized 关键字可以用来修饰方法或者代码块,它的作用是:保证在同一时刻,只有一个线程可以执行被 synchronized 修饰的代码。它就像一把锁,当一个线程获取到锁后,其他线程必须等待,直到该线程释放锁才能继续执行。

2.1 synchronized 修饰方法

synchronized 修饰一个方法时,它锁定的是整个方法。这意味着,当一个线程调用该方法时,它会自动获取与该方法所属对象关联的锁。其他线程想要调用该方法,必须等待该线程释放锁。

public class SynchronizedCounter {
    private int count = 0;

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

    public synchronized int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedCounter counter = new SynchronizedCounter();

        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("最终计数: " + counter.getCount()); // 结果保证是20000
    }
}

在这个例子中,increment()getCount() 方法都被 synchronized 修饰。这意味着,同一时刻,只有一个线程可以执行这两个方法中的任意一个。这有效地避免了数据竞争,保证了 count 的正确性。

2.2 synchronized 修饰代码块

synchronized 也可以用来修饰代码块,语法如下:

synchronized (lockObject) {
    // 需要同步的代码
}

其中 lockObject 是一个对象,它可以是任何对象,只要所有需要同步的线程都使用同一个 lockObject 就可以了。当一个线程执行到 synchronized 代码块时,它会尝试获取 lockObject 关联的锁。如果锁被其他线程占用,该线程就会阻塞,直到锁被释放。

public class SynchronizedBlockCounter {
    private int count = 0;
    private Object lock = new Object(); // 锁对象

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

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

    public static void main(String[] args) throws InterruptedException {
        SynchronizedBlockCounter counter = new SynchronizedBlockCounter();

        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("最终计数: " + counter.getCount()); // 结果保证是20000
    }
}

在这个例子中,我们使用 lock 对象作为锁。只有获取到 lock 对象的锁,线程才能执行 synchronized 代码块中的代码。

3. 锁对象:this 锁 和 类锁

synchronized 关键字背后隐藏着锁对象的概念。Java中的锁对象可以分为两种:

  • this 锁(对象锁):synchronized 修饰一个非静态方法时,它锁定的是 this 对象,也就是调用该方法的对象。
  • 类锁:synchronized 修饰一个静态方法或者使用 synchronized(类名.class) 时,它锁定的是该类的 Class 对象。

3.1 this 锁 (对象锁)

当我们使用 synchronized 修饰一个非静态方法时,锁对象就是当前对象 this。这意味着,不同的对象拥有不同的锁,互不影响。

public class ThisLockExample {
    private int count = 0;

    public synchronized void increment() {
        count++;
        System.out.println(Thread.currentThread().getName() + ": count = " + count);
    }

    public static void main(String[] args) {
        ThisLockExample obj1 = new ThisLockExample();
        ThisLockExample obj2 = new ThisLockExample();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                obj1.increment();
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                obj2.increment();
            }
        }, "Thread-2");

        t1.start();
        t2.start();
    }
}

在这个例子中,obj1obj2 是两个不同的对象,它们拥有不同的 this 锁。因此,Thread-1Thread-2 可以同时访问 increment() 方法,互不阻塞。你会发现输出结果是交替的。

3.2 类锁

当我们使用 synchronized 修饰一个静态方法时,或者使用 synchronized(类名.class) 时,锁对象是该类的 Class 对象。由于一个类只有一个 Class 对象,因此所有该类的对象共享同一个锁。

public class ClassLockExample {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
        System.out.println(Thread.currentThread().getName() + ": count = " + count);
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                ClassLockExample.increment();
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                ClassLockExample.increment();
            }
        }, "Thread-2");

        t1.start();
        t2.start();
    }
}

在这个例子中,increment() 方法被 synchronized 修饰,它锁定的是 ClassLockExample.class 对象。因此,Thread-1Thread-2 必须串行执行 increment() 方法,你会发现输出结果是线程1执行完,线程2再执行。

或者使用 synchronized(ClassLockExample.class)

public class ClassLockExample2 {
    private static int count = 0;

    public static void increment() {
        synchronized (ClassLockExample2.class) {
            count++;
            System.out.println(Thread.currentThread().getName() + ": count = " + count);
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                ClassLockExample2.increment();
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                ClassLockExample2.increment();
            }
        }, "Thread-2");

        t1.start();
        t2.start();
    }
}

效果是一样的。

4. synchronized 的原理:Monitor 对象

synchronized 关键字的底层实现依赖于 Monitor 对象。每个Java对象都关联着一个 Monitor 对象,当 synchronized 关键字修饰的代码被执行时,线程会尝试获取该对象关联的 Monitor 锁。

Monitor 对象内部维护着一个锁计数器,当计数器为 0 时,表示锁未被占用;当计数器大于 0 时,表示锁已被占用。当一个线程成功获取锁时,计数器加 1;当线程释放锁时,计数器减 1。当计数器变为 0 时,锁被释放,其他等待的线程可以尝试获取锁。

简单来说,Monitor 对象的作用就是管理锁的获取和释放,保证线程的同步。

5. synchronized 的特性

synchronized 关键字具有以下特性:

  • 原子性: 保证被 synchronized 修饰的代码块作为一个原子操作执行,不可中断。
  • 可见性: 保证一个线程对共享变量的修改对其他线程是可见的。这是因为,当一个线程释放锁时,它会将工作内存中的共享变量刷新到主内存中;当一个线程获取锁时,它会从主内存中读取最新的共享变量。
  • 有序性: synchronized 可以防止指令重排序。在 synchronized 代码块内部,指令的执行顺序与代码的编写顺序一致。

6. synchronized 的使用注意事项

  • 过度同步: 不要过度使用 synchronized,过度同步会导致性能下降。只对需要同步的代码块进行同步。
  • 死锁: 避免死锁。死锁是指两个或多个线程互相等待对方释放锁,导致所有线程都无法继续执行的情况。
  • 锁的粒度: 尽量减小锁的粒度,提高并发性能。例如,可以使用 ConcurrentHashMap 代替 HashMap
  • 避免长时间持有锁: 尽量缩短持有锁的时间,避免阻塞其他线程。

7. synchronized 的适用场景

synchronized 关键字适用于以下场景:

  • 保护共享数据: 当多个线程需要同时访问和修改共享数据时,可以使用 synchronized 关键字保护共享数据,防止数据竞争。
  • 实现线程安全: 可以使用 synchronized 关键字将非线程安全的类转换为线程安全的类。
  • 控制资源访问: 可以使用 synchronized 关键字控制对共享资源的访问,例如数据库连接、文件等。

8. synchronized 的替代方案

除了 synchronized 关键字,Java还提供了其他线程同步机制,例如:

  • Lock 接口: Lock 接口提供了比 synchronized 关键字更强大的功能,例如可重入锁、公平锁、条件变量等。
  • 原子类: java.util.concurrent.atomic 包提供了原子类,例如 AtomicIntegerAtomicLong 等,可以实现原子操作,避免使用锁。
  • 并发集合: java.util.concurrent 包提供了并发集合,例如 ConcurrentHashMapCopyOnWriteArrayList 等,可以安全地在多线程环境下使用。

9. 总结

synchronized 关键字是Java并发编程中最基本的同步机制之一。它可以保证原子性、可见性和有序性,防止数据竞争,实现线程安全。但是,synchronized 关键字也存在一些缺点,例如性能较低、容易导致死锁等。在实际开发中,需要根据具体情况选择合适的同步机制。

记住,synchronized 是一位忠实的守护者,它可以保护我们的共享数据,防止多线程环境下出现混乱。但是,我们也要合理使用它,避免过度同步,才能充分发挥并发编程的优势。

希望这篇文章能够帮助你更好地理解 synchronized 关键字,并在实际开发中灵活运用。祝你编程愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注