CodeGym /课程 /JAVA 25 SELF /共享资源与同步:并发访问问题

共享资源与同步:并发访问问题

JAVA 25 SELF
第 52 级 , 课程 0
可用

1. 什么是共享资源

你已经见过共享资源了。一个家庭居住的房子是这个家庭的共享资源。办公室的冰箱是所有同事的共享资源。这个概念不难理解。

在编程中,所谓共享资源,是指可被多个线程同时访问的变量、对象或数据结构。它可以是:

  • 用于统计已处理订单数量的计数变量。
  • 一个由某些线程填充、由其他线程处理的请求列表。
  • 一个由多个线程写入的已打开文件。
  • 一个被程序不同部分使用的数据库连接。

在 Java 中,只要某个对象或变量可能被多个线程访问,它就潜在地变成了“共享资源”。

共享资源示例:全局计数器

public class Counter {
    public int value = 0;
}

如果有多个线程要递增这个计数器,它们都会访问同一个变量 value——这就是共享资源。

2. 并发访问的问题

在单线程程序里一切都很简单:一个线程——一个执行者——它会沿着代码像火车沿着铁轨那样直行。但一旦引入多个线程,就会开始真正的“刀光剑影”:线程可能在最意想不到的地方相互干扰。

Race condition(竞态条件)

Race condition 是一种情况:程序的结果取决于线程动作如何“交错”。也就是说,即便多次运行同一个程序,结果也可能不同——这不是 bug,而是多线程的“特性”。

经典示例:两个线程递增计数器

我们来模拟一个简单场景:有一个共享计数器,两个线程各自将其递增一千次。

public class Counter {
    public int value = 0;
}

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

        Runnable incrementTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.value++; // 危险点!
            }
        };

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

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

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

        System.out.println("期望值: 2000");
        System.out.println("实际值: " + counter.value);
    }
}

屏幕上会看到什么?

有时是 2000,有时是 1985,有时是 1937……为什么?因为操作 counter.value++ 不是原子操作!它由三个步骤组成:

  1. 读取当前的 counter.value
  2. 将它加 1。
  3. 写回。

如果两个线程同时读取到相同的值,并各自递增然后回写——最终会丢掉一次递增。这就是丢失更新(lost update)。

对象处于不一致状态

如果你有一个由多个字段组成的复杂对象,而线程同时修改不同的字段,对象就可能落入“奇怪”的或不一致的状态。比如,账户余额减少了,但操作历史没有更新——客户慌了,会计也惊了。

3. 为什么需要同步

同步是一种告诉程序的方式:“停!这段代码同一时间只能由一个线程执行!其他线程——请等待!” 这就像厕所门上的告示“打扫中!请勿入内!”:只要里面有人,外面的人就得等(并在心里吐槽那个待太久的人)。

保证数据完整性

如果希望计数器总是被正确递增,就必须禁止多个线程同时修改它的值。

示例:对计数器递增进行同步

public class Counter {
    public int value = 0;

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

现在,如果两个线程调用 increment(),同一时刻只有一个线程能够执行该方法。另一个线程会等待直到第一个结束。

示意图:同步时发生了什么

+-------------------+
|  Thread 1          |    --\
+-------------------+      \
      |                      \
      V                       \
+-------------------+          > [ synchronized increment() ]
|  Thread 2          |    --/  /
+-------------------+      / /
      |                    / /
      V                   / /
+-------------------+    / /
|  Thread 3          | --/ /
+-------------------+    /
      |                 /
      V                /
+-------------------+ /
|  Thread N          |/
+-------------------+

所有线程都会在受保护的代码段前排队。同一时间只有一个线程能进入“临界区”(synchronized 代码块)。

4. 同步方式概览

在 Java 中,同步并不是唯一的做法,而是一整套“武器库”,用于防止对共享资源的并发访问。

关键字 synchronized

它是 Java 中最基本的同步工具,可以用两种方式:

同步方法

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

同步代码块

public void increment() {
    synchronized (this) {
        value++;
    }
}

这里的 this 是加锁的对象。当一个线程在执行该代码块时,其他想要进入基于同一对象的相同代码块的线程将会等待。

来自 java.util.concurrent 的专用类

  • LockReentrantLock——比 synchronized 更灵活的替代方案。
  • ReadWriteLock——将锁分为读锁和写锁。
  • Semaphore——限制同时执行代码的线程数量。
  • CountDownLatchCyclicBarrier 等——用于协调线程的工作.

重要:今天我们只做基础入门——这些类我们稍后再详细讨论。

5. 实用示例:带多线程计数器的应用

假设我们在为某个服务编写用户访问统计。每个线程代表一位用户,它会递增一个共享计数器。

不使用同步

public class Counter {
    public int value = 0;
}

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

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

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

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

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

        System.out.println("期望值: 30000");
        System.out.println("实际值: " + counter.value);
    }
}

结果:几乎总是小于 30000。有时——小得多!为什么?因为线程会相互“打架”。

使用同步:修正错误

public class Counter {
    public int value = 0;

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

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

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

        Thread t1 = new Thread(user);
        Thread t2 = new Thread(user);
        Thread t3 = new Thread(user);

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

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

        System.out.println("期望值: 30000");
        System.out.println("实际值: " + counter.value);
    }
}

结果:总是 30000。太好了,同步生效了!

6. 有用的细节

可视化:race condition 是如何发生的

我们用一张小表来展示两个线程如何“丢失”一次递增:

步骤 线程 1 线程 2 value 的值
1 读取 value=0 0
2 读取 value=0 0
3 增加到 1 0
4 增加到 1 0
5 写入 1 1
6 写入 1 1

何时需要同步

并非总是需要同步。当变量活在它自己的小世界里,且只被一个线程使用时——可以放松。但一旦与其他线程共享,就必须使用同步。即便看起来“一切应该没问题”——也不要相信。竞态错误很狡猾:它可能长期潜伏,然后在最不合适的时刻突然爆发。

展望:还有哪些同步手段

今天我们仅仅认识了基础工具——synchronized。在后续课程中我们会讨论:

  • 对象监视器如何工作以及锁的类型。
  • 什么是静态同步方法(static + synchronized)。
  • volatile 的作用与用法。
  • 现代的同步类有哪些(LockSemaphore 等)。

7. 使用共享资源时的常见错误

错误 1:忽视多线程。
最常见的错误之一是没有考虑变量可能会被多个线程访问。即使现在程序是单线程的,日后有人加入了线程——bug 就会“凭空出现”。

错误 2:同步不足或过度同步。
如果不对共享资源进行同步——你会得到竞态条件和不一致的数据。如果把所有地方都加上同步,程序会被锁拖垮变得缓慢。始终只同步真正需要的那部分。

错误 3:在错误的对象上同步。
如果在不同的对象上进行同步(例如在局部变量或字符串字面量上),这并不能保护共享资源。所有线程必须在同一个对象上同步。

错误 4:指望非原子操作具有原子性。
操作 i++ 不是原子!即使变量声明为 volatile,也不会让递增变成原子。此类操作需要同步。

错误 5:“我很幸运,一切都运行正常”。
Race condition 可能在你的电脑上不出现,但一定会在服务器或用户那边出现。编写多线程程序时,绝不要靠“运气”。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION