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

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



一、加锁的目的

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代码块执行完后,就会自动释放锁,就不会有忘记或者没的解锁这种情况。

相关文章
|
3月前
|
Java 开发者 C++
Java多线程同步大揭秘:synchronized与Lock的终极对决!
Java多线程同步大揭秘:synchronized与Lock的终极对决!
78 5
|
3月前
|
设计模式 安全 Java
Java并发编程实战:使用synchronized关键字实现线程安全
Java并发编程实战:使用synchronized关键字实现线程安全
57 0
|
13天前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
40 4
|
1月前
|
Java 开发者
在 Java 多线程编程中,Lock 接口正逐渐取代传统的 `synchronized` 关键字,成为高手们的首选
【10月更文挑战第6天】在 Java 多线程编程中,Lock 接口正逐渐取代传统的 `synchronized` 关键字,成为高手们的首选。相比 `synchronized`,Lock 提供了更灵活强大的线程同步机制,包括可中断等待、超时等待、重入锁及读写锁等高级特性,极大提升了多线程应用的性能和可靠性。通过示例对比,可以看出 Lock 接口通过 `lock()` 和 `unlock()` 明确管理锁的获取和释放,避免死锁风险,并支持公平锁选择和条件变量,使其在高并发场景下更具优势。掌握 Lock 接口将助力开发者构建更高效、可靠的多线程应用。
24 2
|
1月前
|
Java 编译器 程序员
【多线程】synchronized原理
【多线程】synchronized原理
57 0
|
3月前
|
安全 Java 开发者
Java多线程同步:synchronized与Lock的“爱恨情仇”!
Java多线程同步:synchronized与Lock的“爱恨情仇”!
86 5
|
3月前
|
Java 开发者
在 Java 多线程编程中,Lock 接口正逐渐取代传统的 `synchronized` 关键字,成为高手们的首选
在 Java 多线程编程中,Lock 接口正逐渐取代传统的 `synchronized` 关键字,成为高手们的首选。相比 `synchronized`,Lock 提供了更灵活强大的线程同步机制,包括可中断等待、超时等待、重入锁及读写锁等高级特性,极大提升了多线程应用的性能和可靠性。通过示例对比,可以看出 Lock 接口通过 `lock()` 和 `unlock()` 明确管理锁的获取和释放,避免死锁风险,并支持公平锁选择和条件变量,使其在高并发场景下更具优势。掌握 Lock 接口将助力开发者构建更高效、可靠的多线程应用。
28 2
|
3月前
|
Java 测试技术
Java多线程同步实战:从synchronized到Lock的进化之路!
Java多线程同步实战:从synchronized到Lock的进化之路!
100 1
|
3月前
|
Java C++
【Java 并发秘籍】synchronized vs ReentrantLock:揭秘线程同步神器的对决!
【8月更文挑战第24天】本文详细对比了Java并发编程中`synchronized`关键字与`ReentrantLock`的不同之处。`synchronized`作为内置关键字,提供自动锁管理但不支持中断或公平锁;`ReentrantLock`则通过显式调用方法控制锁,具备更多高级功能如可中断、公平锁及条件变量。文章通过两个计数器类实例展示了两种机制的具体应用,帮助读者理解其差异及适用场景。掌握这两者对于提升多线程程序设计能力至关重要。
59 0
|
3月前
|
存储 安全 Java
【多线程面试题十七】、如果不使用synchronized和Lock,如何保证线程安全?
这篇文章探讨了在不使用`synchronized`和`Lock`的情况下保证线程安全的方法,包括使用`volatile`关键字、原子变量、线程本地存储(`ThreadLocal`)以及设计不可变对象。