多线程(初阶四:synchronized关键字)

目录

一、加锁的目的

二、加锁和解锁

三、加锁后是否会出现线程安全问题

1、两个线程,针对不同对象加锁

2、一个线程加锁,一个线程不加锁

3、针对加锁操作的一些混淆理解

(1)多个线程调用同一个类的方法,对其方法里面的变量加锁

(2)Test类里的add方法里面,加锁的对象换成Test.class

(3)还可以把synchronized加到方法上

4、同一个线程用同一个锁对象加锁两次(可重入性的例子)

四、死锁

1、产生死锁的四个必要条件

2、死锁的三种经典场景

(1)一个线程,一把锁(针对锁是不可重入锁)

(2)两个线程,两把锁

(3)N个线程,M把锁

五、联系其他的相关知识点

1、StringBuffer 和 StringBuilder

2、C++加锁、解锁和java的区别


一、加锁的目的

synchronized的两大特性:

(1)互斥性

(2)可重入性(后面讲)

因为加锁具有互斥的特性,给一段代码加锁,当运行这段代码时,这里的代码在系统上的指令就会就会被打包在一起,等这些指令,执行完了,其他的指令操作才能进行。而这,也是加锁的目的:把几个操作打包成一个原子的操作。


二、加锁和解锁

我们想让一个变量自增10_0000次,用两个线程来实现这一操作,分工各一半,

没有加锁的操作,是有线程问题的,因为两个线程修改同一个变量的原因。代码如下:

public class ThreadDemo1 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

执行效果:

count并不是我们预期的10_0000。

当我们给count++加上锁操作后的代码:

public class ThreadDemo1 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized(locker) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized(locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

执行效果:

是我们预期的效果。

加锁的最核心规则:对于一个线程,针对一个对象进行加锁,但也有其他线程,也尝试对这个对象进行加锁,就会产生阻塞,这时,我们就把这已现象称为锁竞争 / 锁冲突

例如下图,都是针对locker对象进行加锁,这时就会产生锁竞争

加锁和解锁的执行过程,针对上述代码,简单的画图展示一下:

也就是把t1先上锁,把t1的这几个操作打包成一个原子,执行完它们才能执行t2。

注意:给加锁的对象是任意的引用类型都可以的,我们也可以随便起个对象,但是要记住加锁的核心,两个线程之间加锁的对象,是否是同一个对象,是同一个对象,就会产生锁竞争,反之则不会。


三、加锁后是否会出现线程安全问题

1、两个线程,针对不同对象加锁

如下下代码,加锁用的不是同一个对象,则还是会存在线程安全问题

public class ThreadDemo1 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized(locker1) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized(locker2){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

执行结果:

我们可以看到,结果和我们预期的10_0000不同,所以,还是存在线程安全问题。

2、一个线程加锁,一个线程不加锁

如下代码,和上面代码差不多,做一些小小的改动

public class ThreadDemo1 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
//        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                synchronized(locker1) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

执行结果:

可以看到,和我们预期的结果不同,所以还是存在线程安全问题。

3、针对加锁操作的一些混淆理解

(1)多个线程调用同一个类的方法,对其方法里面的变量加锁

还是之前的代码模板,不过做了一些改动,把count放到Test t 对象中,在这里面count++,并且对其加锁,加锁对象是 this,其他线程再来调用Test中的方法。

class Test {
    public int count = 0;
    public void add() {
        synchronized(this) {
            count++;
        }
    }
}
public class ThreadDemo2 {
    public static void main(String[] args) throws InterruptedException {
        Test t = new Test();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                t.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                t.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(t.count);
    }
}

执行效果:

和我们的预期效果一样,这说明,这种情况是线程安全的。

解释要解决这种情况的线程安全问题,核心还是上面所说的,看加锁是不是同一个对象,这里的Test类,add方法里对其加锁引用的对象是this,也就是当前Test类的实例对象,所有两个线程调用者方法的时候会产生锁竞争,结果也就可以达到我们的预期效果了。

(2)Test类里的add方法里面,加锁的对象换成Test.class

模板和之前差不多,只有一点小改动。代码如下:

class Test {
    public int count = 0;
    public void add() {
        synchronized(Test.class) {
            count++;
        }
    }
}
public class ThreadDemo2 {
    public static void main(String[] args) throws InterruptedException {
        Test t = new Test();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                t.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                t.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(t.count);
    }
}

执行结果:

结果和我们预期的原因,是线程安全的情况。

解释:这里的 Test.class 是类对象(反射那一块的内容),而 t1 和 t2 拿到的都是同一个对象,就会有锁竞争,还是能保障线程安全的。

(3)还可以把synchronized加到方法上(静态和普通方法都行)

如图:

表示锁的对象是当前方法所在的类,不过比较少用。

4、同一个线程用同一个锁对象加锁两次(可重入性的例子)

代码:

public class ThreadDemo1 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
           synchronized(locker)  {
                synchronized (locker) {
                    System.out.println("hello");
                }
           }
        });
        t1.start();
    }
}

看到这段代码,同一个线程用同一个对象加锁两次,我们的第一反应是不是肯定会一直阻塞等待,打印不出来 hello ?答案是否定的,能打印出 hello,我们来看看执行结果:

这是为什么呢?就是因为synchronized有可重入性的特性可以对一个线程用同一个锁对象加锁多次。

接下来,我们来解析一下这段代码,了解一下可重入性的内部操作。

(1)当我们第一次遇到synchronized的 “{ ”时,是真正的加锁,在synchronized内部有一个计数器会自增一次(++)。此时计数器里的值:1

(2)第二次遇到synchronized的 “ { ”,会判断要加锁的线程是否和正在持有锁线程是同一个线程,如果不是同一线程,则会阻塞等待正在持有锁线程,如果是同一线程,则synchronized内部的计数器会自增(++)的操作,就没别的操作了。此时计数器里的值:2

(3)第一次遇到 “ } ”,synchronized里的计数器自减一次(- -),此时计数器里的值:1 。因为计数器不为0,所以不会真的解锁。

(4)第二次遇到 “ } ”,synchronized里的计数器自减一次(++),此时计数器的值:0。因为计数器归零了,会进行真正的解锁。

用计数器的方式,就能避免两个所之间的逻辑,失去锁的保护,也就是锁的可重入的特性。

对于可重入锁来说,内部会持有两个信息:

1、当前这个锁被哪个线程持有的。

2、加锁次数的计数器。

注意:这里的可重入性只针对java的synchronized关键字才有,底层的代码是用C++写的,而C++没有可重入性的这特性。


四、死锁

死锁是多线程代码中的一类经典问题,同样是经典的面试题。加锁能解决线程安全问题,但是如果加锁方式不当,就可能会产生死锁。

1、产生死锁的四个必要条件

(1)互斥使用(获取锁的过程是互斥的,一个线程拿到了这把锁,其他线程想要拿到这把锁,就要阻塞,等待这个线程释放这把锁后,才能拿这把锁)

(2)不可抢占(一个线程拿到一把锁,其他线程不能强行把这把锁抢走)

(3)请求保持(一个线程拿到A锁,在持有A锁的前提下,同时尝试拿到B锁)

(4)循环等待 / 环路等待

破坏上述条件的难易程度:

(1)互斥使用:锁的基本特性,不好破坏。

(2)不可抢占:锁的基本特性,不好破坏。

(3)请求保持:代码结构的原因,看实际需求,有时候能破坏,有时候不能破坏。

(4)循环等待:代码结构的原因,最好破坏,指定一定的规则即可避免循环等待。

2、死锁的三种经典场景

(1)一个线程,一把锁(针对锁是不可重入锁)

锁是不可重入锁,在C++中,一个线程对这把锁加锁两次,就会产生死锁

代码:

public class ThreadDemo1 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
           synchronized(locker)  {
                synchronized (locker) {
                    System.out.println("hello");
                }
           }
        });
        t1.start();
    }
}

以上是针对的是不可重入锁的情况,在C++中逻辑大概是这逻辑,代码肯定是不同风格的。这种情况就会产生死锁的情况。

这种情况也就像现实生活中,我们的钥匙锁房间里了,我们要想进入房间,就要拿到房间里的钥匙,但是房间是被锁着的,我们拿不到。

代码逻辑:遇到第一个synchronized,进行加锁,代码里的内容遇到第二个synchronized,锁对象是一样的,就要阻塞,等待第一个synchronized里的代码执行完才能执行第二个synchronized里的代码,因为第二个被阻塞等待了,所以会一直这样的阻塞的等待下去。

(2)两个线程,两把锁

线程1获取到了锁A,线程2获取到了锁B,这两条件下,线程A想获取锁B,线程B想获取锁A。这种情况,就会产生死锁。

代码:

public class ThreadDemo2 {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (A) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println("我获得了两把锁");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (B) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    System.out.println("我获得了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

执行结果:

这里会一直阻塞等待,我们可以用jconsole看当前线程的状态

这种情况,就像是钥匙锁车里了,车钥匙锁屋里了。

代码解析:线程1获取到了A锁,线程2也获得了B锁,因为线程的调度是随机的,所以接下来执行先执行线程1或者线程2,都行,我们以线程1为例子。当线程1想获取B锁时,因为线程2已经获取了锁B,就要等待线程2的锁B解锁后,线程1才能获取到B锁,但是线程2获取锁B后,它想获取锁A,因为锁A已经被线程1获取了,就要等线程1的A锁解锁后才能获取到锁A,这样就造成了阻塞等待,线程1等待B锁解锁,线程2等待锁A解锁,两个线程里的锁都无法解锁,就一直卡着不动。

解决方案:

破坏产生死锁四个必要条件之一:循环等待。

给加锁指定一定规则,例如:1线程获取A锁后,再获取B锁,2线程获取A锁后,再获取B锁。

代码如下:

public class ThreadDemo2 {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (A) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println("我获得了两把锁");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (A) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    System.out.println("我获得了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

执行结果:

(3)N个线程,M把锁

哲学家就餐问题

假设有五个哲学家,五把锁,一个哲学家就是一个线程,一个筷子就是一把锁,如图:

规则:一个哲学家要吃苗条的话要有一双筷子才能吃,而且只能拿哲学家左右两边的筷子,哲学家在吃面条的时候,不能被别的哲学家抢筷子。

这时,如果每个哲学家,都同时拿起左边的筷子,这时,就没有多余的筷子给哲学家使用吃面条了,谁都不能吃到,谁都在等待,如图:

这就像一个线程,自己已经持有了一把锁,但还在尝试获取一把锁,但是每个不同锁都被不同线程加锁了,这时就等待别的线程释放锁,但是大家也都在阻塞等待别的锁释放,就会产生死锁问题。

那这时,要怎么办呢?只要破坏产生死锁的4个必要条件的其中一个,就能解决死锁的问题,其中破坏循环等待是最简单的。

这时,我们规定,每个哲学家拿筷子编号比自己编号小的筷子,从编号为2的哲学家开始,如图:

到最后,1号哲学家就不能拿筷子了,5号能吃到面条,等5号吃完,4号就能吃,依次类推,每个哲学家就都能吃到面条了。线程也差不多,给加锁指定一定的规则,就能有效避免循环等待问题了。

注意:解决死锁问题不止破坏四个必要条件这么点,解决死锁问题也有很多方案,例如:

(1)引入额外的筷子

(2)去掉一个哲学家

(3)引入计数器,限制同一时间多少个人吃面

(4)引入加锁顺序的规则(普适性高,方案容易落实)

(5)学校操作系统课中的 “银行家算法”(这个方案确实能解决死锁问题,但是实际开发中不建议这么搞,因为这算法太难了,可能你锁的逻辑没问题,算法有bug了,导致整个进程跑不了;理论可行,实践不推荐)


五、联系其他的相关知识点

1、StringBuffer 和 StringBuilder

StringBuffer是线程安全的,就是在一些关键方法上,加上了synchronized;StringBuilder不是线程安全的,就是在一些关键方法上,没加synchronized。

其实通俗的说是不是线程安全,并不严谨,例如上面的代码例子,一个线程加锁,一个线程不加锁,或者两个线程给不同的对象加锁,任然是线程不安全的。具体还是要看代码怎么写。
 

2、C++加锁、解锁和java的区别

C++:它的加锁和解锁和java是不同的,在C++里,加锁:locker.lock()   解锁:locker.unlock(),他们的加锁和解锁是分开执行的,C++这种写法可能导致程序猿忘记调用unlock,或者unlock没执行到,这时就会产生很严重的bug,没解锁,其他加锁用和它一样对象的线程,就会一直等待。

java:它的是使用synchronized方法进行加锁,解锁的,这些操作已经打包好了,当synchronized代码块执行完后,就会自动释放锁,就不会有忘记或者没的解锁这种情况。

猜你喜欢

转载自blog.csdn.net/cool_tao6/article/details/134614834