张三并发编程实践:线程应该如何同步

引言

在现实开发中,我们或多或少都经历过,因为并发的问题,导致的数据不一致的问题,究其原因,是因为在某些场景下,某一个变量值被多个用户访问并修改,那么如何保证该变量在并发的场景过程中正确的修改,保证每个用户使用的正确性呢?今天我们来聊聊线程同步的概念。

一般来说,程序并行化是为了获得更高的执行效率,但有一个非常重要的前提就是是,高效率不能以牺牲正确性为代价。如果程序并行化后,连基本的执行结果的正确性都无法保证,那么并行程序本身也就没有任何意义了。因此,线程的安全问题就是并行程序的根本和不可动摇的根基。为例解决这些问题,我们先聊聊临界区的概念。

我们可以简单地把程序代码分成两部分:不会导致竞争条件的程序片段和会导致竞争条件的程序片段。会导致竞争条件的程序片段就叫做临界区。避免竞争条件只需要阻止多个进程同时读写共享的数据就可以了,也就是保证同时只有一个进程处于临界区内。

💌🧚‍♀️💗🌨🥡🍥 我们可以用_《现代操作系统》_书中的配图来理解。

Java为我们提供了同步机制,帮助程序员实现临界区。当一个线程想要访问一个临界区时,它使用其中的一个同步机制来找出是否有任何其他线程执行临界区。如果没有,这个线程就进入临界区。否则,这个线程通过同步机制暂停直到另一个线程执行完临界区。当多个线程正在等待一个线程完成执行的一个临界区时,JVM选择其中一个线程执行,其余的线程会等待直到轮到它们。

临界区(Critical Section)是指在多线程环境下,多个线程共享的代码段,这些代码段需要互斥地访问共享资源。为了确保临界区的正确性,我们需要遵循以下规则:

  1. 🌈 原子性(Atomicity):临界区内的操作必须是原子的,即要么全部执行成功,要么全部不执行。在临界区内,如果一个线程正在执行操作,其他线程必须等待,直到该线程完成操作。
  2. 🌈 互斥性(Mutual Exclusion):在临界区内,只能有一个线程在执行操作。如果一个线程正在执行临界区内的操作,其他线程必须等待,直到该线程完成操作。
  3. 🌈 有界等待(Bounded Waiting):线程在等待进入临界区时,不能无限期地等待。有界等待意味着线程在等待进入临界区时,最多等待一定次数。这可以防止线程饥饿现象。
  4. 🌈 资源分配图(Resource Allocation Graph) :资源分配图是一种用于描述系统资源分配情况的图形表示。在临界区中,资源分配图必须满足以下条件:
    • 🔬 每个资源节点表示一个共享资源。
    • 🔬 每个进程节点表示一个线程。
    • 🔬 如果一个线程正在使用某个资源,则在资源节点和进程节点之间存在一条有向边。
    • 🔬 如果一个线程正在等待使用某个资源,则在资源节点和进程节点之间存在一条无向边。

根据资源分配图,我们可以判断系统是否处于安全状态。如果系统处于安全状态,那么所有线程都能按照某种顺序执行临界区内的操作,从而避免死锁。

为了实现临界区的正确性,我们可以使用各种同步机制,如互斥锁(Mutex)、信号量(Semaphore)、监视器(Monitor)等。这些同步机制可以帮助我们确保临界区的原子性、互斥性和有界等待。在实际开发中,我们需要根据具体需求选择合适的同步机制,以平衡性能和正确性。

Java提供了多种同步机制来实现临界区,例如synchronized关键字和ReentrantLock类。synchronized关键字可以用于修饰方法或代码块,当一个线程进入被synchronized修饰的方法或代码块时,其他线程必须等待当前线程执行完毕后才能进入。ReentrantLock类是一个可重入的互斥锁,它提供了比synchronized更灵活的锁定和解锁操作。


📄🖍️o(≧o≦)o🧸 Java语言为解决同步问题提供了两种主要的机制: ***synchronized关键字和 Lock***接口及其实现。以下是这两种机制的简要介绍:

Synchronized

synchronized关键字是Java语言内置的同步机制,用于确保同一时刻只有一个线程可以访问共享资源。synchronized关键字可以用于修饰方法或代码块。当一个线程进入被synchronized修饰的方法或代码块时,其他线程必须等待,直到该线程退出方法或代码块。synchronized关键字的实现基于Java虚拟机(JVM)的监视器锁(Monitor Lock)机制。

示例 ☎️

我们使用synchronized关键字修饰increment()和getCount()方法,以确保同一时刻只有一个线程可以访问这些方法。

javascript 复制代码
public class SynchronizedExample {
    private int count = 0;

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

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

Lock

Java提供了java.util.concurrent.locks包,其中包含了Lock接口及其实现,如ReentrantLockLock接口提供了比synchronized关键字更灵活的同步机制。Lock接口的实现类(如ReentrantLock)提供了lock()unlock()等方法,用于显式地获取和释放锁。此外,Lock接口还支持可中断的锁获取、公平锁等特性。

示例 ☎️

我们使用ReentrantLock实现类来实现同步。我们在increment()和getCount()方法中显式地获取和释放锁,以确保同一时刻只有一个线程可以访问这些方法。

javascript 复制代码
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockExample {
    private int count = 0;
    private final Lock lock = new ReentrantLock();

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

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

Java语言为解决同步问题提供了两种主要的机制:synchronized关键字和Lock接口及其实现。synchronized关键字提供了简单易用的同步机制,而Lock接口及其实现提供了更灵活、更强大的同步机制。在实际开发中,我们需要根据具体需求选择合适的同步机制,以平衡性能和正确性。

Synchronized 作用

在Java中,synchronized关键字用于实现线程同步,它可以确保在同一时间只有一个线程可以访问被synchronized修饰的代码块或方法,从而保证共享资源在并发访问时的正确性。以下是synchronized的主要作用:

  1. 保证原子性synchronized可以确保被修饰的代码块或方法在同一时间只被一个线程访问,从而避免了多个线程同时访问导致的数据不一致问题。这样可以确保操作的原子性,使得每个线程都能得到正确的结果。
  2. 实现互斥synchronized可以实现互斥,即在同一时间只允许一个线程访问被修饰的代码块或方法。这样可以防止多个线程同时访问共享资源,从而避免资源竞争和数据不一致的问题。
  3. 保证有序性synchronized可以确保被修饰的代码块或方法按照顺序执行。当一个线程正在访问被synchronized修饰的代码块或方法时,其他线程必须等待,直到当前线程执行完毕。这样可以确保代码的执行顺序,避免出现数据不一致的问题。
  4. 防止死锁 :虽然synchronized可以实现线程同步,但如果使用不当,可能会导致死锁。为了避免死锁,我们需要合理地使用synchronized,避免在一个线程中嵌套使用多个synchronized代码块或方法,以及避免在synchronized代码块或方法中调用可能会阻塞的操作。

☎️ 示例:

increment()和getCount()方法都使用了synchronized关键字,确保了在同一时间只有一个线程可以访问这两个方法,从而保证了count变量的正确性。

javascript 复制代码
public class Counter {
    private int count;

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

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

synchronized关键字在Java中起到了关键作用,它可以帮助我们实现线程同步,确保共享资源在并发访问时的正确性。在实际开发中,我们需要根据具体需求选择合适的同步机制,以平衡性能和正确性。

用法 && 示例 ☎️

synchronized关键字可以用于修饰方法或代码块,以实现线程同步。以下是synchronized关键字的几种用法及示例:

修饰方法: 🍑

synchronized修饰一个方法时,它会在方法执行期间锁定当前对象。在同一时间,只有一个线程可以访问被synchronized修饰的方法,其他线程必须等待。

javascript 复制代码
public class Counter {
    private int count;

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

模拟业务场景:多个用户同时尝试增加计数器的值。

javascript 复制代码
public class CounterDemo {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + counter.getCount());
    }
}

修饰代码块: 🍑

synchronized修饰一个代码块时,它会在代码块执行期间锁定指定的对象。在同一时间,只有一个线程可以访问被synchronized修饰的代码块,其他线程必须等待。

javascript 复制代码
public class Counter {
    private int count;
    private final Object lock = new Object();

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

模拟业务场景:多个用户同时尝试增加计数器的值。

javascript 复制代码
public class CounterDemo {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + counter.getCount());
    }
}

修饰静态方法: 🍑

synchronized修饰一个静态方法时,它会在方法执行期间锁定当前类的Class对象。在同一时间,只有一个线程可以访问被synchronized修饰的静态方法,其他线程必须等待。

javascript 复制代码
public class Counter {
    private static int count;

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

模拟业务场景:多个用户同时尝试增加计数器的值。

javascript 复制代码
public class CounterDemo {
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                Counter.increment();
            }
        };

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + Counter.getCount());
    }
}

修饰静态代码块: 🍑

synchronized修饰一个静态代码块时,它会在代码块执行期间锁定当前类的Class对象。在同一时间,只有一个线程可以访问被synchronized修饰的静态代码块,其他线程必须等待。

javascript 复制代码
public class Counter {
    private static int count;

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

模拟业务场景:多个用户同时尝试增加计数器的值。

javascript 复制代码
public class CounterDemo {
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                Counter.increment();
            }
        };

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + Counter.getCount());
    }
}

修饰类: 🍑

虽然synchronized不能直接修饰类,但我们可以通过修饰类的静态方法或静态代码块来实现类级别的同步。

javascript 复制代码
public class Counter {
    private static int count;

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

模拟业务场景:多个用户同时尝试增加计数器的值。

javascript 复制代码
public class CounterDemo {
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                Counter.increment();
            }
        };

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

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter: " + Counter.getCount());
    }
}

Volatile

volatile关键字和synchronized关键字都可以用于确保多线程环境下的变量可见性和有序性,但它们之间有一些区别。以下是volatile关键字和synchronized关键字的主要区别:

  1. 性能volatile关键字相比于synchronized关键字具有更低的性能开销。volatile关键字仅确保变量的可见性,而synchronized关键字则确保变量的可见性和有序性。因此,在只需要确保变量可见性的场景下,使用volatile关键字比使用synchronized关键字更高效。
  2. 原子性volatile关键字不能保证原子性。原子性是指一个操作要么全部执行成功,要么全部不执行。在多线程环境下,如果一个线程正在执行volatile变量的操作,其他线程可能会看到该操作的部分结果。而synchronized关键字可以确保原子性,即要么全部执行成功,要么全部不执行。
  3. 锁的粒度volatile关键字适用于简单的变量访问和修改场景,而synchronized关键字则适用于更复杂的同步场景,如方法调用、代码块等。volatile关键字仅影响单个变量,而synchronized关键字可以影响整个对象或代码块。
  4. 可见性volatile关键字确保变量的可见性,即当一个线程修改了volatile变量的值,其他线程能够立即看到修改后的值。而synchronized关键字也确保变量的可见性,但它通过锁机制实现,可能会导致线程阻塞。
  5. 有序性volatile关键字不能保证有序性。有序性是指程序中的操作按照一定的顺序执行。在多线程环境下,如果一个线程正在执行volatile变量的操作,其他线程可能会看到该操作的乱序执行。而synchronized关键字可以确保有序性,即程序中的操作按照一定的顺序执行。

在Java语言规范第三版(Java Language Specification, Third Edition)中,对 **volatile**关键字的定义如下:

A field may be declared volatile __, in which case the Java Memory Model ensures that all threads see a consistent value for the variable.**

翻译成中文:

一个字段可以被声明为 ***volatile***,在这种情况下,Java内存模型确保所有线程看到该变量的一致值。

**这里的关键词是"consistent value",即一致值。****volatile**关键字确保了以下几点:

  1. 可见性 :当一个线程修改了volatile变量的值,其他线程能够立即看到修改后的值。
  2. 有序性volatile关键字禁止指令重排序。这意味着在volatile变量的读写操作之前和之后的代码,不会被重排序到这些操作之后或之前。
  3. 原子性 :对于基本数据类型(如intlong等)的读写操作,volatile关键字确保了原子性。但是,对于复合操作(如++--等),volatile关键字不能保证原子性。

需要注意的是,虽然volatile关键字确保了变量的可见性和有序性,但它并不能替代synchronized关键字。在需要更复杂的同步场景(如方法调用、代码块等)时,仍然需要使用synchronized关键字。在实际开发中,我们需要根据具体需求选择合适的关键字,以平衡性能和正确性。在只需要确保变量可见性的场景下,使用volatile关键字比使用synchronized关键字更高效。

可见性

Java内存模型(Java Memory Model,简称JMM)规定了主内存(Main Memory)和每个线程的工作内存(Working Memory)之间的交互规则。在多线程环境下,每个线程都有自己的工作内存,而主内存中存储着共享变量。线程对变量的操作都在工作内存中进行,然后将操作后的值刷新到主内存,以便其他线程能够看到这些变化。

当一个线程修改了一个变量的值,其他线程可能无法立即看到这个变化,因为它们可能在自己的工作内存中缓存了该变量的旧值。这就是可见性问题。为了解决这个问题,Java提供了volatile关键字。

当一个变量被声明为volatile时,它告诉JMM:

不要对这个变量进行缓存优化,确保每个线程都能看到最新的值。 不要对这个变量的读写操作进行重排序。

这样,即使编译器和处理器想要进行优化,也会因为volatile的存在而遵守这些规则。因此,使用volatile关键字可以确保变量的可见性。

以下是一个简单的示例,说明了 ***volatile***关键字如何解决可见性问题:

我们使用volatile关键字修饰counter变量。当两个线程同时调用increment()方法时,它们会直接从主内存中读取和写入counter的值,确保每个线程都能看到最新的值。因此,输出的Counter值应该是2000,表示两个线程都成功地增加了counter的值。

javascript 复制代码
public class VolatileExample {
    private volatile int counter = 0;

    public void increment() {
        counter++;
    }

    public int getCounter() {
        return counter;
    }

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

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

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

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

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

        System.out.println("Counter: " + example.getCounter());
    }
}

让我们通过一个例子来深入了解volatile关键字的作用。

不使用 Volatile 🍿

首先,我们来看一个不使用volatile关键字的简单例子。这个例子中有一个Counter类,它有一个count变量,用于计数。我们有两个线程,每个线程都会增加count的值1000次。

javascript 复制代码
public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

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

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

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

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

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

        System.out.println("Count: " + counter.getCount());
    }
}

理论上,我们期望count的最终值是2000,因为每个线程都增加了1000次。但在实际运行中,由于编译器和处理器可能对代码进行重排序,以及线程之间的可见性问题,count的值可能小于2000。

使用 Volatile 🍿

现在,我们将count变量声明为volatile,并再次运行上面的例子。

javascript 复制代码
public class Counter {
    private volatile int count = 0;

    // 其他代码不变...
}

通过将count声明为volatile,我们告诉Java虚拟机(JVM):

  1. 不要对这个变量进行缓存优化,确保每个线程都能看到最新的值。
  2. 不要对这个变量的读写操作进行重排序。

这样,即使编译器和处理器想要进行优化,也会因为volatile的存在而遵守这些规则。因此,使用volatile后,count的最终值更有可能接近2000。

小结 🍿

volatile关键字提供了一种轻量级的同步机制,主要用于确保多线程环境下变量的可见性。然而,它并不能保证复合操作的原子性。在这个例子中,虽然volatile提高了count的可见性,但increment()方法仍然不是线程安全的,因为count++是一个复合操作(读、改、写)。对于需要原子性保证的场景,我们仍然需要使用synchronized或其他同步工具。

写在最后

在多线程编程中,正确地使用同步机制是至关重要的,因为它们可以帮助我们避免数据不一致、竞态条件等问题,从而提高程序的可靠性和性能。我们应该熟练掌握Java的线程同步机制,并在实际编程中根据具体需求选择合适的同步策略。通过不断地学习和实践,我们可以编写出更加高效、可靠的多线程程序。

相关推荐
测试老哥1 小时前
外包干了两年,技术退步明显。。。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
追逐时光者2 小时前
免费、简单、直观的数据库设计工具和 SQL 生成器
后端·mysql
初晴~2 小时前
【Redis分布式锁】高并发场景下秒杀业务的实现思路(集群模式)
java·数据库·redis·分布式·后端·spring·
盖世英雄酱581362 小时前
InnoDB 的页分裂和页合并
数据库·后端
小_太_阳3 小时前
Scala_【2】变量和数据类型
开发语言·后端·scala·intellij-idea
直裾3 小时前
scala借阅图书保存记录(三)
开发语言·后端·scala
ThisIsClark3 小时前
【后端面试总结】深入解析进程和线程的区别
java·jvm·面试
星就前端叭3 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
9527华安4 小时前
FPGA多路MIPI转FPD-Link视频缩放拼接显示,基于IMX327+FPD953架构,提供2套工程源码和技术支持
fpga开发·架构·音视频