线程安全简述

目录

1、线程是否安全

2、出现线程安全的原因如下:

3、原子性问题

4、synchronized关键字

1、锁对象

2、用法:

3、可重入锁

5、内存可见性

6、volatile关键字

7、JMM


1、线程是否安全

线程不安全就是一些代码在多线程的运行状态下,达不到预期的运行效果出现bug。如果在多线程的各种随机调度上,代码都没有bug,能以预期的结果运行那么该线程就是安全的。

2、出现线程安全的原因如下:

1、线程之间的抢占式执行,对线程的调度过程是随机的。

2、多个线程同时修改一个变量,并且修改的操作不是原子的

3、内存可见性问题

4、执行的重排序

3、原子性问题

原子性就是一个行为,要么执行完毕,要么就不执行。或者是执行到一半中断的话,能够恢复如初。例如我们去银行转账操作,扣款,转账两个操作必须是一起完成的。不然的话可能就会造成一边扣款了,另外一张卡却没有打上钱。所以有些操作必须是原子性的,也就是要么不执行,执行的话就执行到底。不能中断,中断的话就恢复如初。

有些Java语句看上去是一个语句一个操作。但是可能在CPU执行的时候就被拆成了好几个操作执行,这在我们多线程执行中就会出现bug,这样的代码就是线程不安全。

例如:count++;该操作就会在CPU执行时分成三个步骤,分别是加载进入CPU(load)、自增(add)、加载出CPU(save)。当我们执行下列代码时就会出现线程安全问题。

public class Demo5 {
    public static void increase(){
        sum++;
    }
    public static int sum=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase();
                }
            }
        });
        Thread t2 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase();;
                }
            }
        });

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

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

        System.out.println(sum);
    }
}

两个线程各执行了自增5w次的操作,结果却如下: 

因为我们线程之间的调度是完全随机的,所以就有可能发生下列情况,此时看似两个线程都执行了自增的操作,但是count却是只增加了1。线程1在进行自增的时候count变量已经被线程2取出,所以只加了1。 

 为了解决这样的问题我们就需要给自增这个操作加上锁。加上锁之后自增这三个操作就变成了原子性的。不可拆开执行,也就是线程1再执行这三个操作的时候,线程2处于阻塞状态等待获取锁,当线程1执行完毕这三个操作的时候,才会释放掉锁。此时若没有其他线程等待获取锁那么就是线程2拿到锁并且执行自增操作。操作如下:

1

 值得注意的是,如果此时有多个线程等待获取锁,那么谁拿到锁这个行为也是随机的,如图所示

 此时是谁进去面试就是一个随机情况,此时多个面试者去竞争面试机会,就是一个典型的锁竞争。

如何加锁?

4、synchronized关键字

我们一般使用synchronized关键字进行加锁操作。

1、锁对象

在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器,多线程同时访问某个对象时,线程只有获取了该对象的锁才能访问。

看到有些博客上面写的对象锁、类锁,方法锁,这种说法其实不太严谨,其实只有一种锁,也就是可以把方法锁和类锁,都归为对象锁。方法锁的锁对象是this对象,类锁锁的是该静态方法的class对象。这也对应了synchronized的三种用法

2、用法:

1、修饰方法

修饰方法也就是针对this对象加锁,在方法前加上synchronized即可加锁。

public class Demo5 {
    public static synchronized void increase(){
        sum++;
    }
    public static int sum=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase();
                }
            }
        });
        Thread t2 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    increase();;
                }
            }
        });

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

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

        System.out.println(sum);
    }
}

此时,结果就是100000了。

2、修饰代码块

 此时我们要明确锁上哪一段的代码,在代码设计时多锁和少锁代码都可能导致代码线程不安全。其次就是明确加锁对象,如果针对两个不同的对象加锁那么是不会发生锁竞争的。

class Counter{
    public int sum=0;
    public void increase(){
        synchronized (this){
            sum++;
        }
    }
}
public class Demo6 {


    public static void main(String[] args) throws InterruptedException {
        Counter counter=new Counter();
        Thread t1 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        });
        Thread t2 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();;
                }
            }
        });

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

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

        System.out.println(counter.sum);
    }
}

 3、修饰静态方法

修饰静态的方法其实就是修饰该类的class对象,如下

class Counter4 {
    public static int sum = 0;

    public static void increase() {
        synchronized (Counter4.class) {
            sum++;
        }
    }
}

3、可重入锁

假如我们有一个线程,给自己上了锁,但是由于代码逻辑实现,导致该线程有个自己上了一次锁。但是在上锁的时候,我们要先获取到锁,所以此时该线程就进入阻塞状态,不再执行。但是由于该线程已经上锁,又无法进行解锁操作,而且只有该线程能解开这把锁。此时就陷入了死锁。

但是我们的synchronized是一个可重入锁,因此不会发生以上的问题。

因为:Java的synchronized实现了两个功能

1、记录下哪个线程加了锁。

当该线程第二次加锁时就检测到,该线程已经加过锁了,此时就直接放行不再加锁

2、维护一个计数器。

用来衡量什么时候时真的加锁,什么时候该真的解锁,啥时候该放行

5、内存可见性

此时我们只是解决了原子性问题,但是还是有其他的可能导致线程不安全

什么是内存可见性问题?

public class Demo13 {
    static class Counter {
        public int count = 0;
    }

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

        Thread t1 = new Thread(() -> {
            while (counter.count == 0) {

            }
            System.out.println("t1 执行结束. ");
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            System.out.println("请输入一个 int: ");
            Scanner scanner = new Scanner(System.in);
            counter.count = scanner.nextInt();
        });
        t2.start();
    }
}

阅读以上代码,也就是我们在t2线程中把count改为一个非0的值,我们的t1线程中的while就会停止循环(循环条件是count==0)。但是当我们运行时发现t1没有结束。

因为我们的判断count==0的操作,其实时两个操作读取内存load,比较cmp。但是load操作要比cmp操作慢的多,所以我们的编译器会进行一些优化,也就是只读一次内存,虽然效率提升了但是却带来了这样的问题。

6、volatile关键字

volatile关键字就可以解决上述问题,我们使用volatile修饰一个变量,就可以使该变量不再进行优化,保证了内存可见性。

值得注意的是,volatile只是解决了内存可见性问题,但是并没有解决原子性问题,也就是如果有两个以上的线程同时修改我们的count变量时还是线程不安全,所以还要加上synchronized关键字。

7、JMM

还是内存可见性问题,如果我们在硬件层面看内存可见性问题,其实是编译器优化导致只读了寄存器,不去读内存,因为寄存器的读取速度要比内存快得多。

Java为了让上述说法更加严谨引入了新的术语JMM(Java Memory Model)用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

正常一个程序执行过程中,会把主内存的数据,先加载到工作内存中,在进行计算处理,编译器优化可能会导致不是每次都真的读取主内存,而是直接去工作内存中的缓存数据(可能导致内存可见性)volatile可以保证每次读取内存都是从主内存读取。

猜你喜欢

转载自blog.csdn.net/dghehe/article/details/126822612