并发编程之四:并发之共享问题、线程安全、synchronized关键字

共享模型之管程

并发之共享模型:管程
管程通过悲观锁的思想来解决并发问题。
管程的定义:可以把它简单粗暴的理解为:进程同步工具。
在这里插入图片描述
解决并发的两种思路,共享模型、非共享模型

共享问题

多线程并发访问共享资源时所遇到的一些问题。
java中多线程共享问题的体现

public class Test1 {
    static int counter = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    counter ++;
                }
            }
        }, "t1");

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    counter --;
                }
            }
        }, "t2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug(counter);
    }
}

结果可能会有刚好等于0的情况,但是大多数都是不等于0的
在这里插入图片描述
问题分析
以上的结果可能是正数、负数、零。为什么呢?因为Java中对静态变量的自增,自减并不是原子操作,要彻
底理解,必须从字节码来进行分析
例如对于i++而言(i 为静态变量),实际会产生如下的JVM字节码指令:

在这里插入图片描述
而对应i–也是类似
在这里插入图片描述
而Java的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:
在这里插入图片描述
如果是单线程以上8行代码是顺序执行(不会交错)没有问题:多线程的情况下可能出现的问题,比如线程2获取cup时间片,取出i,然后此时i为0,然后进行–,然后将i的值减为-1,但是此时还没有进行写入操作,所以静态变量i的值仍然为0,线程2的时间片用完了,就发生了线程的上下文切换问题(线程从使用cpu时间片到不使用cpu时间片的过程)。然后线程1获得的时间片,开始执行,由于线程2没有写入,此时线程1获取到的i仍然为0。于是线程1进行++的,一路骚操作,将i的值从0改为1,并且也写入了静态变量i里。然后此时线程1的时间片用完了,于是线程2开始执行,然后他就把刚才自己的-1写入到了静态变量i中了。于是如果线程1再去取值的话,本来应该是1,但是却变成了-1。所以如果正常的线程2执行完再执行线程1,那么值是为0的。由于线程的指令交错运行就成了负数。类似于数据库中数据的脏读。
在这里插入图片描述

synchronized

从字面意思是“同步”,小名“对象锁”,它的作用是去解决多线程的并发问题,它采用互斥的方式让同一时刻至多只有一个线程能持有[对象锁],其它线程再想获取这个[对象锁]时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

临界区

一段代码内如果出现对共享资源的多线程读写操作,称这段代码块为临界区。一个程序多个线程是没有问题的。问题出现在多个线程访问资源。多个线程读共享资源其实也没有问题,在多个线程在对共享资源进行读写操作时发生的指令交错,就会出现问题。一段代码内如果出现对共享资源的多线程读写操作,称这段代码块为临界区。
在这里插入图片描述

竞态条件

多线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。如下图中的两个for循环中的代码便发生了竞态条件。
在这里插入图片描述

如何解决临界区的线程安全问题呢(阻塞式、非阻塞式解决方案)

  • 阻塞式解决方案:synchronized(对象锁)、lock
  • 非阻塞式解决方案:原子变量

synchronized阻塞式解决方案(解决临界区的线程安全问题)

synchronized解决方案(阻塞式解决方案、解决临界区的线程安全问题)

*应用之互斥
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
1、阻塞式的解决方案: synchronized, Lock
2、非阻塞式的解决方案:原子变量
这次我们采用阻塞式的解决方案: synchronized, 来解决上述问题,即俗称的[对象锁],它采用互斥的方式让同一时刻至多只有一个线程能持有[对象锁],其它线程再想获取这个[对象锁]时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
注意
虽然java中互斥和同步都可以采用synchronized关键字来完成,但它们还是有区别的。
.互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码。
. 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点。

synchronized基本语法
// 该对象可以是任意的对象,但是的保证多个线程对同一个对象使用synchronized,即多个线程使用同一把锁,否则不起作用的。
synchronized (对象)
    {
        临界区
    }

Synchronized工作原理:比如有多个线程执行上面这段代码。当线程1获得这个对象上的锁之后,如果线程2又进来了,这时候线程2就获取不到这个对象的锁了,因为现在这个锁的拥有者是线程1,同一时刻只能有一个线程持有对象锁(这也就是为什么说synchronized是互斥的)。此时线程2就会陷入阻塞状态(blocked)。然后线程1就可以安全的执行临界区的代码。当线程1执行完毕后,他会释放锁资源并且唤醒还在阻塞的其它线程。比如此时的线程2被唤醒了,才有机会去获取到这个锁对象的资源,然后去执行临界区的代码。换句话说,加了这个synchronized之后被它包括的临界区的代码就变成了串行了,只有当一个线程执行完,另一个线程才回去执行。

比比这么多,不如上代码来的直接。上面的例子一个公用的变量count,一个线程对其++,另一个对其–,得到的结果很大可能性不是0。但是我们加锁后再来看一下

public class MySynchronized {
    public static int count = 0;
    static Object lock = new Object();// 该对象可以是任意,但是不能为空
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    synchronized (lock) {
                        count++;// 临界区
                    }
                }
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    synchronized (lock) {
                        count--;// 临界区
                    }
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

运行结果,怎么运行都是0,这就对了,说明没有发生竞态条件,有那么一瞬间恍惚回到了高中时代写数学题的感觉,穷尽脑回路,写了满满3张演算纸,答案是个0…
在这里插入图片描述
结果分析:为什么加上了synchronized之后计算就正确了呢(不想听博主啰嗦的同学直接看图就行啦),不过我还是要说,谁让我是话痨来着。首先,假如是线程2去获得资源锁,然后资源锁没有被其它线程占用,于是线程2获得了资源锁,然后呢,线程2进行自减操作,0 -1 = -1 。算完了,但是还没有写入。然后线程2的cpu时间片用完了,发生了线程的上下文切换。然后线程1获得了cpu时间片,于是线程1尝试获取资源锁,但是由于资源锁被线程2锁占用,于是线程1就陷入了阻塞状态。然后又发生了线程的上下文切换。之后线程2又得到了cpu时间片,然后线程2把上次计算完的值-1,写入。然后一直这样,在线程2没有计算完毕synchronized里的代码的时候,资源锁是不会被释放的,于是线程1就没有机会下手,没法对i做点啥。于是只能等线程2计算完毕synchronized里的代码,然后再释放锁资源,然后线程1再去和其它线程竞争所资源,线程1竞争到了,然后才会执行线程1的计算。所以所有的计算都是正确的,也就不会发生竞态条件了,不存在脏读的问题了。
在这里插入图片描述
在这里插入图片描述
思考.
synchronized实际是用对象锁保证了临界区内代码的原子性,这里的原子性解释为:临界区内的代码对外是不可分割的,不会被线程切换所打断。例如上图中的,线程2读取到i的值,计算i的值,写入i的值这3个步骤是不可分割的,不会被打断的。
为了加深理解,请思考下面的问题

  • 1、如果把上面代码的synchronized(obj)放在for循环的外面,如何理解? 如下代码
Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock) {// 该对象可以是任意,但是不能为空
                    for (int i = 0; i < 50000; i++) {
                            count++;// 临界区
                     }
                }
            }
        });

如果把synchronized放在for循环外面,那么for循环里对共享变量count的5000次操作就都是原子性的,不可被分割的,即不管该线程进行多少次上下文切换,在该线程没有对共享变量进行操作完毕之前(完毕后才会释放资源锁),其它线程是不能得到对象锁的,也就无法对共享变量count进行写的操作。该问题强调的是原子性(synchronized实际是用对象锁保证了临界区内代码的原子性

  • 2、如果t1 synchronized(obj1)而t2 synchronized(obj2)会怎样运作?
    此时因为添加的不是同一个锁,所以t1线程与t2线程得到的是不同的锁,各不相干,各自对变量进行操作的时候都得到了锁(因为是不同的锁,所以就不存在阻塞的问题了),于是,5000次的加减又会发生原先的问题,即最后的结果很大可能不是0.由此可鉴,要想保护共享资源,必须确定多个线程锁住的是同一个对象,也就是对同一个对象进行加锁。该问题强调的是对象锁(synchronized实际是用对象锁保证了临界区内代码的原子性
  • 3、如果t2 synchronized(obj)而t1没有加会怎么样?如何理解?
    如上图所示,如果线程1没有加synchronized,那么线程1在得到cpu时间片的时候,就不会去请求所资源,然后它也就不会被阻塞(因为此时线程2拥有锁资源)。于是线程1理所当然的拿到了共享变量,然后就对共享变量做了点啥,就起不到多线程对共享变量的同步操作了,于是结果就又有问题了。该问题强调的是对象锁,同步,阻塞(synchronized实际是用对象锁保证了临界区内代码的原子性
    该问题强调的是对象锁(synchronized实际是用对象锁保证了临界区内代码的原子性
代码块上的synchronized(面向对象改进)

其实上面的代码算是面向过程的,现在我们将它改为面向对象的方式
代码如下:

/**
 * @Author: llb
 * @Date: 2021/3/18 18:07
 */
public class MySynchronized {
    public static int count = 0;
    static Object lock = new Object();// 该对象可以是任意,但是不能为空
    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    room.increment();
                 }
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                   room.decrement();
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

class Room {
    private int count;

    public void increment () {
        // 这里这个锁对象我们用Room自己
        synchronized (this) {
            count++;
        }
    }

    public void decrement() {
        // 上面既然用了Room这里也用this,
        // 因为在操作同一个共享变量的时候,要保证多个线程使用的是同一把资源锁
        synchronized (this) {
            count--;
        }
    }

    // 因为获取的是一个值,为了保证获取到的不是一个中间结果,我们也要对获取的代码进行加锁。
    public int getCount() {
        synchronized (this) {
            return count;
        }
    }
}

在这里插入图片描述
以上代码还可以直接将synchronized加在方法上,因为方法increment与decrement都是成员方法(非静态方法),并且锁住的是this对象。将synchronized加在成员方法上,锁住的是this对象。

方法上的synchronized
成员方法上的synchronized

synchronized加在成员方法锁住的是this对象
如下代码
在这里插入图片描述

静态方法上的synchronized

synchronized加在静态方法上,锁住的是类对象。即synchronized的括号里写的是哪个对象,锁住的就是哪个对象synchronized(类对象){}
在这里插入图片描述

不加synchronized的方法

不加synchronized的方法,是没有办法保证代码的原子性的,该方法就是非同步的。如果该方法中的代码有对共享变量进行操作的话那么结果就会有问题(发生竞态条件,即结果未知)。

synchronized练习题(线程八锁)

各位大佬,如果有一个类,a,b方法都添加了synchronized那么当a被线程1调用,并且还未执行完毕的时候,其它线程是不能调用方法b的吧?(答案:不能,牢记,锁是互斥的,就是一个对象被一个线程锁住了,那么其它线程是无法得到资源的,也就无法得到锁,所以也就无法得调用方法b,此时其它线程处于等待队列(java线程的状态))。如果方法b没有加synchronized呢?(b方法可以正常运行,因为b方法没有加锁,那么调用b方法也就不需要等待锁资源)
1、只要锁是在成员方法里,那么锁住的就是this对象。
2、多个线程锁住的是同一个对象。

锁一
一下代码锁住的谁?
在这里插入图片描述

1锁钥匙:(前提:synchronized锁在了成员方法上,锁对象就是this)这就是一个类中的a,b方法都加了锁,然后线程1调用了a(但是a未执行完毕,所以a还没有释放锁资源),那么b方法还能被线程还能被其它线程调用吗(不能)?上面代码里锁住的是谁(this),此时不管哪个线程先得到锁,那么其它的线程就得不到锁,就处于等待队列。

锁二
一下代码锁住的谁?
在这里插入图片描述
第二把钥匙:(前提:synchronized锁在了成员方法上,锁对象就是this)(锁住的仍然是this,因为synchronized在成员方法上)如果是线程1得到锁资源,那么线程1先睡眠(sleep并不会使线程释放所资源,wait会使线程释放并且在wait的时间之后,并不会获得锁资源,需要其他线程调用notify,notifyall方法该线程才会被唤醒,然后重新竞争锁资源),再执行,这个过程中线程2是在等待队列的,然后线程1释放所资源后,线程2得到锁资源然后线程2再执行。如果是线程2先得到锁,那么线程2先执行,然后释放所资源,然后线程1的到所资源后先随眠再执行。所以打印结果有两种情况
情况1:1s后输出1,然后输出2
情况2:先输出2,然后1s后输出1

锁三

钥匙3:(前提:synchronized锁在了成员方法上,锁对象就是this)上图中多了一个没有synchronized的c方法,在调用的时候c方法是不会互斥的,因为它没有加锁,其他线程调用它的时候根本就不会考虑锁,也就没有互斥了,也就是说,不管哪个线程得到了对象的锁,c方法都可以被其他线程所调用。此题打印结果丰富多彩,大家自行脑补。

锁四
在这里插入图片描述
钥匙4:首先先决条件是成员方法加了synchronized,所以锁对象是this,但是,因为调用方法a,b的时候传递的分别是n1,n2,所以它们是两个对象。对于线程1,它的锁对像this是n1,线程2的锁对象this指的是n2,于是线程1,与线程2之间没有互斥关系,打印结果是,先2,然后过1s再打印1.

锁五
在这里插入图片描述
钥匙5:方法a是静态方法上加synchronized,所以它锁住的是类对象即,number的类对象number.class。而b方法是成员方法上添加synchronized锁住的是this。所以再调用时b方法锁的时n1对象,而a方法是number的类对象是不同的对象。所以线程之间不会互斥,所以是并行执行。注意:(类名.方法名直接调的方法,静态方法是随着类的创建而创建,随着类的销毁而销毁,它与对象没有关系,所以只要是静态方法加锁了,它的锁对象就是类对象,与线程使用什么对象去调用该方法没有关系)。打印结果因该是先2,然后线程1睡眠1s后再打印1.

锁六
在这里插入图片描述
钥匙6:因为方法a、b都是静态方法加synchronized,所以它们锁住的都是类对象即Number.calss,(类对象在内存中只有一份)所以它们锁住的是同一个对象,所以它们会互斥。注意:这里是静态方法(可以类名.方)
打印结果,如果先执行线程1:1s后打印1紧接着2
如果先执行线程2:先打印2,1s后打印1

锁七
在这里插入图片描述
钥匙7:方法a,是静态方法加synchronized,所以它锁住的是类对象即number.class,所以线程1的锁对象是number.class。而方法b是成员变量,锁对下是this,所以线程2锁住的是n2对象。它们不是同一个对象,所以它们不互斥。
打印结果:它们是并行执行的,所以结果是先打印2,过1s打印1
锁八
在这里插入图片描述
钥匙8:方法a、b都是静态方法所以锁住的都是类对象即Number.class。虽然调用的时候传递的是不同的对象,但是方法是静态的(类名.方法名直接调的方法,静态方法啊是随着类的创建而创建,随着类的销毁而销毁,它与对象没有关系)所以它与用那个对象调用也没关系,所以它们锁住的都是Number.class类对象,所以它们互斥。
打印结果:情况1,1s后先1后2
情况2:先2,1s后再1

线程安全分析

变量的线程安全分析

接下来,我们来分析,那些变量是安全的,那些变量是有安全隐患的。

成员变量和静态变量是否线程安全?
成员变量和静态变量是否线程安全?
如果它们没有共享,则线程安全。
如果它们被共享了,根据它们的状态是否能够改变,又分两种情况。
	如果只有读操作,则线程安全。
	如果有读写操作,则这段代码是临界区,需要考虑线程安全。
局部变量是否线程安全?
局部变量是否线程安全?
	局部变量是线程安全的。
	但局部变量引用的对象则未必(如果局部变量引用的是堆中得变量,那就未必是安全的了)。
	如果该对象没有逃离方法的作用范围,它是线程安全的。
	如果该对象逃离方法的作用范围,需要考虑线程安全。
局部变量线程安全分析
普通局部变量(局部变量非引用对象)

如下代码,如果方法test1将来被多个线程去执行,那么i需不需要加保护?答案是不需要。
之前咱们分析过i++操作在底层里是分好几步来进行的,会有读写时线程的上下文切换的情况。但是咱们之前分析方法的调用的时候说过,每个线程调用test1()方法时局部变量i会在每个线程的栈帧内存中被创建多份,而且每个线程的栈帧都是相互独立的,因此不存在共享
知识回顾:[点击查看第二节:线程的运行原理]
1、每个栈由多个栈帧(Frame) 组成,对应着每次方法调用时所占用的内存
2、每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
3、栈帧以线程为单位,每个线程有自己的栈和栈帧它们是相互独立的)。
在这里插入图片描述
如下图在test1被多个线程同时调用的时候,线程0里创建一个test1的栈帧,线程1里也会创建一个线程1的栈帧。这两个个栈帧存在各自的线程之内,它们两个并不是共享的。test1里的局部变量i,也是每个栈帧里私有的内存去存储这个变量的值。所以当我们在做线程的自增操作的时候,线程0做的是它内存里的自增,线程1做的是线程1里的自增,他们各不干扰,所以没有共享,并不存在线程安全问题。
可以简单的将这种情况理解为一下情景:老师拿了一张卷子(test1方法),然后小明拿着它在打印机里复印一份(线程0,将方法加载的自己的内存空间里,在自己的栈帧里创建一个test1的栈帧)。然后小华(线程1)也进行同样的操作。所以他们之间是各自的卷子(test1),他们随便可以在自己的试卷上写写画画,写的东西并不会跑到其它人的试卷上去。
在这里插入图片描述

成员变量是引用对象

以下方法在调用method2和method3的时候会不会有问题呢?(多线程操作的时候存在安全问题)
method2与method3都是对共享变量list进行的操作,有以下情况的时候,比如刚开始的时候,method2还没有往list里添加元素(此时list为空),然后method3先执行了,去移除list中的元素,所以就会抛出著名的索引越界异常了。(注意下图中方法修饰符因该是private)
在这里插入图片描述
分析:对于以上代码进行分析。
线程0调用mehod1的时候会在线程0里创建method1的栈帧,然后method1再调用method2、3,然后线程0,再分别创建method2、3的栈帧。同理,线程1里面也会创建这三个方法的栈帧。但是不管是线程0还是线程1它们进行修改的都是同一个list对象(这里的list是一个成员变量(对象),方法里的list前面还有一个this,是this.list,即使是每个方法单独的去加载,加载的也是对象的引用(遥控器),这些引用都共同的去指向了对象的地址值(电视),所以最终结果操作的还是这个地址值,所以就会出现共享的操作),所以多个线程调用的时候,以上代码的临界区就是for循环中的method2(),与method3(),所以对共享资源有读写操作,所以就有问题了。
1、无论哪个线程中的method2引用的都是同一个对象中的list成员变量
2、method3 与method2分析相同。

在这里插入图片描述

局部变量是引用对象

但是如过将上面的list改为局部变量呢?(多线程操作无安全问题)
在这里插入图片描述

分析:
1、list是局部变量,每个线程调用时会创建其不同实例,没有共享
2、而method2的参数是从methodl中传递过来的,与method1中引用同一个对象
3、method3 的参数分析与method2相同
对于线程0:当我们的线程0调用method1的时候,它首先在堆里创建(new)一个list对象,当调用method2的时候,method2用的是method1传递过来的list对象,method3用的也是method1传递过来的对象。
对于线程1:当线程1调用method1的时候,它也会去new一个新的list对象。method2与method3调用的也都是线程1的method1所创建的list对象。
所以线程0与线程1所操作的list对象是他们自己分别创建的对象,不是同一个对象,各个线程在进行自己的增加和删除的时候都是对自己的变量进行修改的,不存在共享问题。正所谓没有共享就没有伤害。所以以上代码在被多线程调用的时候不会出现问题。在这里插入图片描述
所以将引用变量由成员变量改为了局部变量就不存在多线程操作变量时候的安全问题了。

局部变量暴露引用

我们先看一下怎么把一个局部变量引用的对象给暴露在外部?
**场景1:**看下图的代码,如果method1的修饰符是public,而method2与method3的修饰符都是private的,这种情况下,有没有可能将局部变量list的引用暴露在外部?答案是不会的。因为method2、3使用的list是method传递过来的,所以method1、2、3用的是同一个list变量。又因为list是局部变量,每个线程在调用method1的时候都会在自己的栈帧里面创建一个自己的list,是每个线程所私有的,不存在共享问题,所以也就没有 线程的安全问题了,所以局部变量list不会暴露在外部。
在这里插入图片描述

**场景2:**方法访问修饰符带来的思考,如果把上诉代码method1的修饰符是public,把method2和method3的方法修改为public 会不会代理线程安全问题?
情况1:有其它线程调用method2和method3。因为之前method2、3都改为public的时候(一个类的私有方法只能在该类中调用,即使是创建了该类的对象,也无法调用该类的私有方法)。当它们的修饰符是public的时候,其它线程也能通过该类的对象去直接调用方法2、3,而不是通过method1去调用。比如,让线程1调用method1,与此同时让线程2去调用method2会不会有问题呢?答案也是没问题的。因为线程1调用method1,当线程1调用方法1的时候,在线程1里创建一个方法1的栈帧,然后会在栈帧内存里会创建一个list对象(栈帧是每个线程私有的)。线程2在调用method2的时候当然也会传递一个list对象进去,这个对象是线程2创建并传递进去的,于是线程2创建一个方法2的栈帧,所以线程1的list不是同一个对象。也就没有变量的共享问题了。所以没有线程安全问题。
场景3:在场景2的基础上,为ThreadSafe类添加子类,子类覆盖method2或method3方法,代码如下图。那么这种情况有没有问题呢?答案是有的。如果创建一个ThreadSafe的子类对象,然后有线程通过该对象调用了method1然后method1又会调用method3然后method3里用的变量list是从method里传递过来的,到这之前都没有问题,但是method3的重写方法里又开启了一个新的线程,该线程使用的也是method1里的list,然后这就是两个线程共用一个变量了,于是list就变成了共享变量了,所以就有问题了。这就是由于创建了子类,子类复写父类的方法,并且在子类覆写方法里又创建了其它的线程使用这些传递过来的局部变量(list是方法1的局部变量,又作为参数传递给了方法3),所以不能控制子类的行为,于是就有可能将局部变量的引用暴露给了其它线程。从而引起了线程安全的问题。
所以这里给大家一个提示,我们方法的访问修饰符private是有意义的,在一定程度上它能够保护我们线程的安全。因为private修饰了方法,子类是不能够修改这些方法的。所以就不会有上述的问题了。所以建议在方法method1,也就是公用方法上添加final,防止子类去修改它,做一些不可描述的事情,然后造成线程安全问题。
从这个例子可以看出private或final提供[安全]的意义所在,面向对象编程原则中的开闭原则中的[]原则。就是我不想让子类改变我的行为我就将它保护起来,用private或final或则其它方式都可以。这样可以在一定程度上加强线程的安全性。
在这里插入图片描述

常见线程安全类

常见线程安全类包括但不限于下列列举出来的各个类
String
Integer、Short、Integer、Long、Float、Double、Byte、Character
StringBuffer
Random
Vector(线程安全的list实现)
Hashtable(线程安全的map实现)
java.util.concurrent包下的类(简称juc)
这里说它们是线程安全的,是指多个线程调用它们(指上面的线程安全类)同一个实例的某个方法时,是线程安全的。这里的线程安全可以理解为
1、它们的每个方法内部的临界区代码是原子的(原子性解释为:临界区内的代码对外是不可分割的,不会被线程切换所打断)
2、但注意它们多个方法的组合不是原子的,见后面分析。
注意:成员变量添加了final也不一定是线程安全的。例如一个成员变量添加了final Date date = new Date();添加fianl后不能被修改指的是,date对象指向的地址值不能被修改,即如果在给date = new Date()赋值会报错的.但是date对象里的属性一些方法是不加synchronized的,是线程不安全的,还是可以被修改的所以即使一个成员变量添加了fianl它也不一定是线程安全的。要看它的属性是否能被修改。

例如hashTable里的put方法,这些方法都是同步的即加锁的。
在这里插入图片描述
看一下hashtable的put的源码,虽然有很多行代码,但是它不会被线程的上下文切换所打断(原子性),所以它是线程安全的。
在这里插入图片描述

线程安全类方法的组合。为什么线程安全类多个线程安全方法的组合不是原子的?。
如下代码,我们的需求是当hashTable的key为空的时候我们向其中put一个值。
首先多个线程执行下面代码时候用的是同一个对象table,其次HashTable得get与put方法都是非静态方法且添加了synchronized,所以他们用的是同一个锁对象table。可能有以下情景,线程1得了资源锁然后执行了if里得get方法,执行完毕然后释放所资源,然后所有得线程再去竞争该锁资源,然后线程2得到了所资源,然后执行完了get方法,然后释放锁资源。此时线程1、2又重新竞争所资源,然后线程2得到了所资源,然后向里面放了一个值v2,然后释放所资源,然后线程1得到所资源又往里面放了一个V1得值,此时线程2设置得值就被覆盖了,于是就有问题了。以上场景用的是同一把锁,为啥还有问题呢,原因是因为操作不是原子性。应该是get与put得整个过程不被打断,才算是原子性,所以解决问题得方法是将if外面加上synchronized。同一个锁对象,而且还是原子性,问题得到解决。所以这些线程安全类得多个安全方法在执行得时候,是可能有安全问题得,解决方法就是将这多个安全方法外面也加上锁,然后确保用的是同一个对象,问题就得到解决了。
在这里插入图片描述

不可变线程安全性

String、Integer等都是不可变类,因为其内部的状态不可以改变(只能读,不能写),因此它们的方法都是线程安全的,有同学或许有疑问,String 有replace, substring 等方法[可以]改变值啊,那么这些方法又是如何保证线程安全的呢?
例如string的substring,截取字符串,并不是改变了字符串的值,而是创建了一个新的字符串。所以它没有改动变量的属性而是用新的对象来实现对象的不可变效果。

练习题
  1. 第一题

:servlet是运行在tomcat环境下的它只有一个实例,所以肯定被tomcat的多个线程共享使用,因此它里面的共享成员变量会被共享使用,我们来看一下,哪些成员变量是安全的哪些不是安全的。
在这里插入图片描述
答案:HashMap是线程不安全的map,hashTable是线程安全的map。
String是线程安全的,因为它是不可变的。
final String,同上。
Date D1,不是线程安全的。
Date D2,不是线程安全的:D2的当前时间是不能变得,即D2指向的内存地址是不可变的,此时再给D2 = new Date();是是会报错的,但是它里面的其它属性是可以变得,例如,年、月、日等。日期于String最大的区别是,日期里的属性是可以改变的是线程不安全的。而字符串不一样,它里面的属性不可被更改,所以是线程安全第的。而例如Date属性是可以发生修改的,那么它就不是线程安全的。

  1. 第二题
    在这里插入图片描述
    答案:不是线程安全的。因为servlet在tomcat中只有一份,userService是servlet的一个成员变量,所以也只有一份。所以userService是会被共享使用的。所以count也是一份,然后update方法会被多个线程调用,所以update里的count++就是一个临界区。因此userService不是线程安全的。所以以后要避免上面这样的代码,要么对count++做加锁的操作。要么不要这样去完成。
    方法2:遇事不决,默念口诀。因为servlet只有一个实例,所以useService也只有一个实例,然后useService有成员变量count,虽然该成员变量是私有得(在该类以外得其它地方不能修改),但是呢,在该类里得update里对成员变量有读写操作,所以不是线程安全得。

天地无极,乾坤正法

 成员变量和静态变量是否线程安全?
	如果它们没有共享,则线程安全。
	如果它们被共享了,根据它们的状态是否能够改变,又分两种情况。
		如果只有读操作,则线程安全。
		如果有读写操作,则这段代码是临界区,需要考虑线程安全。
  1. 第三题
    aop做切面的时候使用,给它加一个前置通知和后置通知,避免做重复的工作,把他们统一添加到切面上,所有符合切面表达式的类里的方法在调用二号结束时都会分别执行前置和后置方法。下面代码就是方法调用前记录一个时间,方法调用后记录一个时间,然后得到这个方法的执行时间。
    在这里插入图片描述
    答案:不是线程安全的,首先类上没有添加@scope说明它是单例的,既然是单例的就要被共享,那么它里面的成员变量也是要被共享的。所以会有线程安全的问题。解决方法,可以再做一个环绕通知,然后将end于start做成环绕通知里的局部变量来解决这问题。注意:判断是否是安全的看他是成员变量还是局部变量。成员变量就按成员变量的方式去判断。局部变量就按局部变量的方法去判断,是局部引用,还是不是局部引用。不要把知识点混肴了,然后看到成员变量不是引用类型就觉得没有安全问题,这是错误的,这个老毛病的改改。

  2. 第四题

在这里插入图片描述
解析:是线程安全的。首先我们得明白我们搞了这么打一章,到底线程安全问题是啥,就是多个线程对同一个变量进行操作得时候会发生类似于数据库得脏读得问题,此时改变量就是一个共享变量了,导致数据不准确。
解析1:我们先看userDaaoImp,首先它没有成员变量,其次它里面的方法update里的变量都是局部变量,有多少个线程进来线程就会在他们自己的栈帧里创建不同的conn对象,因此不存在共享问题。所以update里的try-catch是安全的。
解析2:UserServiceImp类里有成员变量,,虽然servlet只有一份,然后userserviceImp也只有一份,所以userDao也只有一份,但是整个类里没有对成员变量得属性有修改操作。也就是只有读,没有写操作。并且该成员变量是私有得,也没有其它地方能修改它(private修饰得方法只能在当前类中进行修改)。所以该成员变量是安全得。
解析3:servlet只有一份,然后userserviceImp也只有一份,但是userserviceImp它里面代码有成员变量,但是该变量是私有得,所以也没有其它地方去修改它,所以里面得成员变量是不可变得userDao,但是没有对成员变量做写得操作,并且它里面得代码也都没有共享问题,所以userserviceImp也是线程安全得。

玄心奥妙,万法归一

 成员变量和静态变量是否线程安全?
	如果它们没有共享,则线程安全。
	如果它们被共享了,根据它们的状态是否能够改变,又分两种情况。
		如果只有读操作,则线程安全。
		如果有读写操作,则这段代码是临界区,需要考虑线程安全。
  1. 第五题

在这里插入图片描述
答案:不是线程安全的,这里由于servlet是一份得,所以导致userserviceImpl与userDao都是一份得。userDao里面得conn是一个成员变量,而且userDao里得update方法对该成员变量有修改得操作。所以不是线程安全得。
场景:例如线程1执行了 doGet方法一路过关斩将到了userDaoImpl得update方法里,然后线程1创建了一个conn对象,创建完毕后,发生了线程得上下文切换。此时线程2进入了update方法,然后它也给conn赋值了,然后又发生了线程得上下文切换,此时线程1,得到cpu时间片,执行完毕,然后执行了conn.close();释放了连接。此时再发生线程上下文切换,当线程2进来得时候,conn对象已经被释放,所以既有问题了。
总结:所以我们以后在写代码得时候,要将这种conn连接对象携程方法内的局部变量就不会有问题了(局部变量在每个线程的帧栈里都会被创建一份,而且帧栈是线程所特有的。不存在共享问题)。

  1. 第六题
    在这里插入图片描述
    解析:是线程安全的,为什么呢?因为servlet是单独一份的,然后userService也是单独一份的,然后userService里的update方法里的userDao是一个局部变量,所以不管有多少线程调用userService,线程都会在自己的内存中创建usercie.update的栈帧,然后再在栈帧中创建userDao,所以所有线程的userDao都是不同的,都在线程自己的栈帧中,虽然userDao的update方法里有成员变量,虽然userDaod.update对成员变量也有修改,但是他们修改的是自己的conn,因为这个成员变量在每个线程的栈帧里,不存在共享的问题,所以以上代码是线程安全的。

  2. 第七题

在这里插入图片描述
答案:其中foo的行为是不确定的,可能导致不安全的发生,被称之为外星方法。代码里得foo方法是不确定得,如果它得字类实现了这个方法,做了些不可描述得事情,那就不好说了。如下面这情况,首先SimpleDateFormat不是线程安全得,其次,foot方法得实现里又开启了一个新的线程。虽然sdf是局部变量,但是多个线程公用一个sdf对象,该对象得引用被暴露了出去,那就是共享,所以就是线程不安全得了(可以参考本章节得【局部变量得引用】中得场景2、3)。所以不想往外暴漏得方法或者变量就给它设置为私有,在一定程度上能够增加线程得安全性,面向对象得开闭原则。比如jdk中得String。面试得时候也是经常问道得,为啥String要设置成final?如果不设置成final它得字类就有可能覆盖掉string中方法得一些行为,就有可能导致不安全的事情发生。

在这里插入图片描述

买票案例

代码:以下代码模拟了售票窗口共有2000张票,然后多个人去购买。代码运行完毕后看剩余票数+已售出票数如果等于2000那么以下代码就没问题,如果不等于2000那就说明有线程安全问题。多线程的安全问题,并不是每次都会现的,可以多运行几次。大家分析一下下面的代码看是否有问题?

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.Vector;

/**
 * @Author: llb
 * @Date: 2021/3/25 19:08
 */
public class ExerciseSell {

    static Random random = new Random();

    //随机1~5
    public static int randomAmount() {
        return random.nextInt(5) + 1;
    }

    public static void main(String[] args) {
        // 表示共有2000张票
        TicketWindow ticketWindow = new TicketWindow(2000);
        List<Thread> list = new ArrayList<>();
        //用来存储买出去多少张票
        List<Integer> sellCount = new Vector<>();
        for (int i = 0; i < 4000; i++) {
            Thread t = new Thread(() -> {
                //分析这里的竞态条件
                int count = ticketWindow.sell(randomAmount());// 用来模拟每个线程购票数量不等
                sellCount.add(count);
            });
            list.add(t);
            t.start();
        }

        // 让所有得线程都执行完毕,然后再打印下面得结果。
        list.forEach((t) -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        //买出去的票求和
        System.out.println("共卖出:" + sellCount.stream().mapToInt(c -> c).sum());
        //剩余票数
        System.out.println("剩余票数:" + ticketWindow.getCount());
    }
}

// 售票窗口
class TicketWindow {
    private int count;

    public TicketWindow(int count) {
        this.count = count;
    }

    // 获取余票数量
    public int getCount() {
        return count;
    }

    // 售票
    public int sell(int amount) {
        if (this.count >= amount) {
            this.count -= amount;
            return amount;
        } else {
            return 0;
        }
    }
}

打印结果:
在这里插入图片描述
首先分析线程安全问题,先要找到临界区,临界区定义:对共享变量有读写操作的代码就属于临界区。上面代码的临界区就是for循环买票的时候:
1、所以ticketWindow中的sell里的所有代码就都是临界区。
2、还有for循环里的sellCount.add,它也是临界区,add方法对sellCount的元素有读写操作,但是我们不用考虑它因为Vector对add方法添加了synchronized。
3、我们需不需要考虑1、2的组合的线程安全问题呢,答案是不需要的,因为他俩操作的不是一个变量。这和我们之前提到的例子不太一样,我们上面提到的安全类的安全方法的组合不一定是安全的例子中,put方法是对同一个共享变量hashTable进行操作的,所以它们的组合有安全问题。但是我们这里只需要分别保证每个方法里的临界区代码收到保护就可以了,并不需要考虑他们的组合问题。
4、代码中的list.add是不是临界区呢,答案不是的。因为它没有在线程的run方法里,只是被主线程使用,没有被多个线程共享。所以它用普通的ArrayList就可以了,而没必要用Vector
过程分析:在for循环里调用ticketWindow.sell方法,对于线程来说,所有的线程调用的是同一个ticketWindow对象,其次sell方法中对TicketWindow的成员变量count有读写的操作,所以count变量是共享变量,所以线程不安全。

解题:此题的问题在于ticketWindow被多个线程所共共享,然后多个线程对TicketWindow类中的成员变量count有读写操作,所以我们保证在临界区添加synchronized即可。更保险的方法是将count变量设置为private,以防止其它线程里直接用ticketWindow对象修改该变量。当然此题中原本就是private。

转账练习

分析以下代码是否有线程安全问题

import java.util.Random;

/**
 * @Author: llb
 * @Date: 2021/3/26 16:39
 */
public class ExerciseTransfer {
    public static void main(String[] args) throws InterruptedException {
        Account a = new Account(1000);
        Account b = new Account(1000);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                a.transfer(b, randomAmount());
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                b.transfer(a, randomAmount());
            }
        }, "t2");
        t1.start();
        t2.start();
        // 等待t1,t2都结束后再打印结果
        t1.join();
        t2.join();
        //查看转账2000次后的总金额
        System.out.println("total:" + (a.getMoney() + b.getMoney()));
    }

    // Random为线程安全
    static Random random = new Random();

    //随机1~100.
    public static int randomAmount() {
        return random.nextInt(100) + 1;
    }
}

class Account {
    private int money;

    public Account(int money) {
        this.money = money;
    }

    public int getMoney() {
        return money;
    }

    public void setMoney(int money) {
        this.money = money;
    }

    //转账
    public void transfer(Account target, int amount) {
        if (this.money >= amount) {
            this.setMoney(this.getMoney() - amount);
            target.setMoney(target.getMoney() + amount);
        }
    }

}

运行结果:总金额因该是2000才算正确。
在这里插入图片描述
分析:转账操作因该是2个账户之间转账,所以这里用2个线程来模拟2个账户,然后进行多次转账如果最后金额还是2000那么代码就是线程安全的。该代码中临界区为Account .transfer方法。因为在每一个transfer方法里两个线程都对共享成员变量money有读写操作。这里有个问题a,b是两个对象,所以有两个成员变量money都是共享变量。

方法1添加synchronized等价于下面的方式

 // 转账
    public void transfer(Account target, int amount) {
        synchronized (this) {
        // 相当于只是对当前对象的money起了保护作用
            // 相当于只保护了this.money,而没有保护target里的money
            // 我们要把他们两个都保护起来,才能解决问题
            if (this.money >= amount) {
                Account.setMoney(this.money - amount);
                target.setMoney(target.getMoney() + amount);
            }
        }
    }

然后我们如果在方法上加synchronized锁,运行结果还是不正确,这是为什么呢?因为transfer方法在执行的时候同时对两个对象a,b都进行了读写的操作,当两个线程都执行transfer方法的时候由于线程的上下文切换,就导致了,两个线程都对a,b对象里的money将型读写操作数据就乱了。synchronized加在普通成员方法上,锁住的是this对象,所以这里如果加了锁,代表分别锁住了a,b对象。syhchronized要锁就得锁住同一个对象,怎么样才可以锁住唯一的对象呢?
方法二:所以另一种情况就出现了,我们将方法transfer改为静态方法,然后再在上面添加上synchronized,此时锁住的就是Account.class。我们知道类对象在内存中只有一份,所以此时就解决问题了。但是这种方法也是有弊端的就是效率低,如果有 很多的账户都转账,那么同一时间将只能有2账户可以转账。
在这里插入图片描述

 //转账
    public static void transfer(Account target, int amount) {
        synchronized (Account.class) {
            if (Account.money >= amount) {
                Account.setMoney(Account.getMoney() - amount);
                target.setMoney(target.getMoney() + amount);
            }
        }
    }

以上写法等价于

 //转账
    public synchronized static void transfer(Account target, int amount) {
        if (Account.money >= amount) {
            Account.setMoney(Account.getMoney() - amount);
            target.setMoney(target.getMoney() + amount);
        }
    }

此题也可以把a,b都锁柱,但是容易出现死锁。

这篇文章只是对synchronized有了初步的了解,下一篇博客,我们来学一下它的底层原理。

我曾七次鄙视自己的灵魂: 第一次,当它本可进取时,却故作谦卑; 第二次,当它空虚时,用爱欲来填充; 第三次,在困难和容易之间,它选择了容易; 第四次,它犯了错,却借由别人也会犯错来宽慰自己; 第五次,它自由软弱,却把它认为是生命的坚韧; 第六次,当它鄙夷一张丑恶的嘴脸时,却不知那正是自己面具中的一副; 第七次,它侧身于生活的污泥中虽不甘心,却又畏首畏尾。 —卡里·纪伯伦撒旦

个人笔记,不喜勿喷。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值