【JavaEE】多线程之线程安全(中)

发布于:2025-08-14 ⋅ 阅读:(16) ⋅ 点赞:(0)

嘿嘿,咱们书接上回~(提示:介绍完了多线程程序可能存在的一些线程安全问题,接下来我们逐一介绍解决方案)

二、加锁

2.1 正确使用synchronized

关键字:synchronized,此处翻译为“互斥”
我们对某一段代码加锁解锁,前提是要有一个锁,锁可以是任意类型的,但是锁生效的条件是有多个线程使用同一个锁对象加锁,也就是锁对象的类型不重要,重要的是是否由多个线程对同一个对象加锁

一般来说,我们都会专门创建一个对象作为锁对象

举个例子

Object object = new Object();
Thread t1 = new Thread(()->{
    for (int i = 0; i < 5000; i++) {
        //两个线程分别对count++过程加锁
        synchronized (object){
            count++;
        }
    }
});

Thread t2 = new Thread(()-> {
    for (int i = 0; i < 5000; i++) {
        //两个线程分别对count++过程加锁
        synchronized (object) {
            count++;
        }
    }
});

只有这样两个线程针对同一对象(locker)加锁,才会产生互斥现象,即一个线程加上锁,只有当这个线程释放锁之后另一个线程才能使用这个对象进行加锁

换言之,锁生效的前提是需要有两个线程针对同一个对象加锁

2.2 synchronized的一些说明

  1. 为了保证代码的高内聚低耦合特性,即使将对象作为锁对象不影响对象的其他使用,也不会对这个对象进行其他修改,而是仅仅作为锁对象这一个用途
  2. 为什么要使用synchronized代码块而不是更加直观的lock()、unlock()方法呢?(其实Java中也有lock、unlock的写法,只不过不常用)主要原因是,使用代码块的写法,进入代码块即加锁,出代码块即解锁,对于程序员来讲操作成本更低,而使用lock、unlock很容易在一些复杂代码中遗漏掉unlock而产生问题
  3. synchronized的变种写法——使用synchronized修饰方法,这里的锁对象是this对象,此时就相当于将方法体整个写在synchronized块内,和直接使用synchronized代码块的效果是一样的

2.3 可重入

不知道大家在看到锁操作的时候有没有想到一个问题,不同线程针对同一个锁对象加锁,会产生互斥效果,但是如果一个线程针对一个锁对象进行多次加锁,会发生什么呢?

还是来分析一波,第一次加锁操作能够成功,第二次加锁的时候锁对象已经被占用了,会触发阻塞等待,此时需要前一次加锁操作结束后锁被释放,但是第一次操作想要释放锁对象,需要先让第二次加锁操作结束(也就是第二次操作释放锁对象)后才能释放锁对象,此时无法执行第二次加锁,前一次的加锁也就无法释放,闭环了属于是……这样的情况被称为死锁(dead lock)

emmm,有点绕,我们还是来看代码

Object locker = new Object();
synchronized(locker){
// 第一次加锁
	synchronized(locker){
	// 第二次加锁,此时由于前一次加锁还没有释放掉,这里阻塞等待
	// 但是前一次解锁的前提是这个synchronized代码块执行结束,也就是第二次加完锁后释放锁
	// 然而由于第一次加锁没有释放,第二次无从获得锁,此时阻塞等待
	// 第一次加锁等第二次加锁,第二次加锁等第一次释放锁,两个代码块大眼瞪小眼,谁也运行不了。。。
		//balabala(执行的任务,这里不重要)
	}
}

为了解决上述问题,Java的synchronized引入了可重入的概念,即尽管对一个锁对象进行了多次加锁,也不会卡死的操作

可重入:可以重复进行加锁操作且不会卡死
原理:让锁对象内部标记当前是哪个线程持有的这把锁,后续有线程针对这个已经加锁的锁对象加锁时会进行对比,检查锁持有者的线程是否和当前加锁的线程是同一个

也就是当某个线程针对一个锁加锁成功后,后续该线程再次针对这个锁进行加锁时不会触发阻塞,而是继续执行块内代码,如果是其他线程尝试加锁,就会正常阻塞


Q:如果有多个synchronized代码块包裹,如何判断哪一个“}”是真正需要执行的?
A:引入一个变量计数器,每次触发“{”的时候把计数器++,每次触发“}”的时候,计数器–,当计数器–为0的时候,就是要真正解锁的时候


Q:如何实现一个可重入锁?
A:在锁内部记录当前是哪个线程持有的锁,后续每次加锁都进行判定;通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁

2.4 如何避免死锁

上面的例子我们已经知道,一个线程多次对同一个锁对象加锁会导致死锁,这只是其中一种产生死锁的情况,,当然,还有其他原因

2.4.1 死锁是怎样构成的?

构成死锁需要以下四个必要条件

  1. 锁是互斥的(一个线程拿到锁之后,另一个线程尝试获取锁,必须要阻塞等待)
  2. 锁是不可抢占的(一个线程拿到锁之后,其他线程如果想要拿到锁,只能阻塞等待,而不能抢过去【大家都比较文明】)
  3. 请求且保持(一个线程在保持上一个锁的情况下,请求另一个锁)
  4. 循环等待(多个线程,多把锁之间的等待过程中构成了循环,圈圈圆圆圈圈~)

前两点相比大家都比较了解,这也是锁的基本特性

至于请求且保持和循环等待,我们再来看个例子

public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(()->{
            synchronized (locker1){
                System.out.println("t1拿到第一把锁");

                //如果不加sleep,可能t1线程都执行完了,t2再执行
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker2){
                    System.out.println("t1尝试拿到第二把锁");
                }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (locker2){
                System.out.println("t2拿到第二把锁");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (locker1){
                    System.out.println("t2尝试拿到第一把锁");
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }

这个例子中,t1和t2两个线程都是在拿到了一把锁之后试图拿对方线程的锁,t1等t2,t2等t1,二者一同阻塞等待,从而构成死锁

再生活化一点,我和妹妹一起吃饺子,蘸酱油和醋,我俩都既想蘸酱油又想蘸醋,我拿起酱油,她拿起醋,我俩都不肯放下手里的,又想拿对方手里的,谁都不会让步,结局就是谁都吃不了饺子

对于循环等待,本质上就是以上的过程,只不过可能存在更多的线程,再再来个例子

一共5个人,5杯奶茶,1拿到了第一杯,2拿到了第二杯,……,5拿到了第五杯,一人喝了一口发现味道还不错,于是还想尝一下别人的,1想拿2的,2想拿3的,……5想拿1的,每个人都在等下一个人主动放弃他的,但又都不肯放下自己的,也就都喝不到,尴尬的等待开始了……

避免死锁,主要是破环掉3或者4

2.4.2 避免死锁的方法

  1. 代码中加锁的时候避免嵌套(通用性不够)
  2. 破除循环等待——约定好加锁的顺序,确保所有的线程都按照同一种加锁顺序

对于第一种方法,我们可以使用synchronized加锁,因为它本身就是可重入的,也就是其实嵌套加锁也不会造成死等

对于第二种,则是规定好加锁的顺序,例如在上面喝奶茶的例子里,我们规定只能拿小于等于自己号码的奶茶,也就是1只能拿第一杯奶茶,2只能拿第一或第二杯奶茶,……,这样就不会构成循环,从而避免面面相觑的尴尬情景(死锁)

2.5 Java标准库中线程安全类

线程不安全的集合类(集合类自身没有进行任何加锁限制):

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

线程安全的集合类:

  • Vector(不推荐使用)
  • HashTable(不推荐使用)
  • StringBuffer
  • ConcurrentHashMap
  • String

前三个类在关键方法上加了synchronized修饰
ConcurrentHashMap相比于HashTable来说是一个高度优化的版本
String虽然没有修改,但是不涉及“修改”,所以仍是线程安全的

为什么前两个不推荐使用呢,因为加锁本身也存在弊端,代码又可能因为锁的竞争产生阻塞,使程序的效率大打折扣

okk,锁相关问题就介绍到这里,关于线程安全中内存可见性问题等我们下节继续~


网站公告

今日签到

点亮在社区的每一天
去签到