在 JavaEE 开发中,线程安全是一个至关重要的问题。当多个线程同时访问和修改共享资源时,如果不采取适当的措施来确保线程安全,就可能会导致数据不一致、竞态条件、死锁等问题,从而影响系统的稳定性和可靠性。

在本文中,我们将深入探讨 JavaEE 线程安全问题,包括线程安全的概念、线程安全的实现方式、常见的线程安全问题及解决方法等。通过本文的学习,读者将对 JavaEE 线程安全问题有更深入的了解,并能够在实际开发中避免和解决线程安全问题。

一、什么是线程安全

首先,我们在学习如何让一个线程变得安全的前提,要先知道:什么是线程安全,什么样的情况下线程会变得不安全???先看下面代码:

class Count {
    private int count = 0;
    public void addCount() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
public class Demo9 {
    public static Count count = new Count();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count.addCount();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count.addCount();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count.getCount());
    }
}

大家看一下这个代码,很好理解吧???t1t2 两个线程对 count 变量进行加法运训,按照我们自己的逻辑思考,最后的结果应该是 10000 才对,可是实际上的结果可不一样,结果如下:

线程安全问题_System

这个结果是不是出乎各位的意料呢?这下大家明白什么是线程不安全了吗?通俗点讲就是在多线程操作中,如果说实际的结果和我们预期的(想要的)结果不符,这个线程就是不安全的。这里要注意,一般来说读操作,是不会产生线程不安全的,只有写会(一般来说!!!)。


二、如何实现线程安全

好了,现在来解释上述代码为什么会出现这种情况。

如果说大家了解 ++ 这个指令,那肯定就知道怎么回事了,这个指令分为三个步骤,首先是每个线程读入 count 这个变量的值,然后是再让 count 加 1 ,然后再把结果返回给 count ,这就导致了 count++ 这个操作不具有原子性,在多个线程执行的时候,有可能多个线程同时拿到 count 的值,然后进行 ++ 操作,再返回给 count ,这就导致了,明明 count 是要变化两次的,但是实际上就只变化了一次,那我们如何把这个代码变成线程安全的代码呢?很简单嘛,让 count++ 这个操作变成原子性的操作不就行了吗?那如何变得原子性呢?这就涉及到我接下来要讲的加锁操作了。


2.1 synchronized - 锁操作

这个操作是给某段代码加上一把锁,实现方式如下:

class Count {
    private int count = 0;
    
    // 直接在方法名上加锁,相当于整个方法都需要锁
    synchronized public void addCount() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

public class Demo10 {
    public static Count count = new Count();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count.addCount();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count.addCount();
            }
        });

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

        t1.join();
        t2.join();
        System.out.println(count.getCount());
    }
}

还有另外一种实现加锁的方式:

class Count {
    private int count = 0;

    public void addCount() {
        // 在某段代码中加锁,表示只是这段代码需要锁,括号里面的参数可以是当前的类也可以新创建一个类
        synchronized (Count.class) {
            count++;
        }
    }

    public int getCount() {
        return count;
    }
}

public class Demo10 {
    public static Count count = new Count();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count.addCount();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count.addCount();
            }
        });

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

        t1.join();
        t2.join();
        System.out.println(count.getCount());
    }
}

这两种代码的结果都一样,都是10000。

那为什么加上一个 synchronized 就会变得正确了呢,这个加锁解锁又到底是个什么东西???

带着这些疑问,我们接着往下看。举个例子:

图书馆借书大家都知道吧,例如一本书叫《罗密欧与钢铁侠的爱情故事集》,你去图书馆,想借这本书来看,但是发现已经被借走了,这时候,借走的那个人就相当于给这本书上了一把锁,还书之后就相当于解锁,之后在他把这本书还回来了,你才能重新借这本书,然后你给这本书加锁,不然就得一直阻塞等待,上面的 synchronized 操作也是一样的,当一个线程拿到 count++ 这个操作时,其他的线程想尝试拿到这个操作时,会产生锁竞争,只能进行阻塞等待,当这个线程修改好了,解锁了之后才能尝试拿到这个操作对这个操作重新上锁。


2.2、volatile - 内存可见性/指令重排序

那什么又是关于内存可见性的线程安全问题呢?先来看一段代码:

import java.util.Scanner;

public class Demo11 {
    public static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 1) ;
            System.out.println("循环结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            flag = sc.nextInt();
        });

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

这个代码相信大家都能看懂吧?简单点说就是 t1 线程负责执行循环任务,t2 线程负责控制 t1 线程的结束时间,如果说输入其它的数字就结束 t1 线程中的循环,然后打印 "循环结束" 这个语句,但是最后执行下来的结果却与我们的预期结果大相径庭,实际结果如下:

线程安全问题_加锁_02

可以看到,无论我如何输入,t1 线程始终无法结束,这个原因就涉及到设计 Java 的那群大佬,自动给你代码优化的问题了,正常情况下,这种优化是非常好的,但是这是多线程,多线程的情况特别复杂,所以造成了这种线程不安全的结果。这个优化大致就是在我们创建出 flag 这个属性的时候, t1 线程里就读取了 flag 的数据,复制了一份,方便后面运行的时候不需要频繁的读取数据,所以就算我们在外面更改了 flag 的值, t1 线程也读取不到,这就导致了我们的 t1 线程无法停止下来,而我们的 volatile 关键字,相当于就是给 cpu 提个醒,告诉它我们这个值随时会变,每次都得读取检查一遍这个值,代码如下:

import java.util.Scanner;

public class Demo11 {
    volatile public static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 1) ;
            System.out.println("循环结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            flag = sc.nextInt();
        });

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

这个代码运行之后的结果就和我们预期的结果相同了。


总结:确保线程安全的艺术

在探索了多线程编程的复杂世界后,我们学到了一个关键的教训:线程安全是构建可靠和高效软件系统的基础。通过本文的介绍,我们了解到线程安全的重要性远远超出了避免数据损坏的范畴,它关乎整个应用的稳定性和用户的信任。

我们讨论了 synchronizedvolatile 两个关键字,它们是Java中确保线程安全的基石。 synchronized 为我们提供了互斥和原子性,它通过锁定机制保证同一时间只有一个线程能够执行特定的代码块或方法。 volatile 则通过确保变量的内存可见性和防止指令重排序,来维护变量的安全性。

我们认识到,虽然这两个关键字是强大的工具,但它们并不是解决所有并发问题的银弹。在实践中,我们必须谨慎使用它们,并理解它们的使用场景和限制。例如, synchronized 可能会导致性能问题,尤其是在高竞争环境下,而 volatile 尽管能解决可见性问题,但对于复合操作则需要额外的同步措施。

记住,线程安全不仅是一个技术问题,更是一种责任和对质量的承诺。让我们一起致力于构建更好的软件,为用户带来更加稳定可靠的体验。


结语:拥抱挑战,共铸代码的诗篇

在并发编程的世界里,每一个线程安全问题的解决都是对技术的深刻洞察和对质量的坚持。面对挑战,我们不是孤立的,每一位开发者的努力汇聚成创新的洪流,推动着软件工程的边界不断扩展。

让我们一起拥抱这些挑战,不断学习、成长,以匠人精神雕琢每一行代码。让我们守护代码的净土,使其在多线程的复杂环境中绽放稳定之光。

愿我们共同努力,不仅为了代码的高效运行,更为了在这片数字宇宙中,创造出能够启发、服务、并赋予人们力量的软件杰作。

祝代码之旅精彩无限,愿我们的程序永远稳定运行!

线程安全问题_加锁_03