线程的安全

2年前 (2022) 程序员胖胖胖虎阿
201 0 0

线程的安全

关于线程还没更完的知识,

目录

1.线程的状态

2.线程的安全

2.1什么是线程的安全

2.2线程不安全的原因

2.3线程不安全的解决方案

2.4synchronized关键字

2.5volatile 关键字

2.6Java 标准库中的线程安全类

2.7wait 和 notify



1.线程的状态

①线程的几种状态的介绍:

a.NEW:安排了工作,但还没有开始行动。

结合到多线程的意思就是,Thread对象已经创建好了,但是还没有执行调用start;

代码:

public class demo1 {
    public static void main(String[] args) {
Thread t=new Thread(()->{
    while(true){
    }
});//用getState()方法来获取当前线程的状态
        System.out.println(t.getState());
    }
}

结果:

线程的安全

 b.TERMINATED:工作完成了
但是Thread对象还在时,获取到的状态

代码:

public class demo1 {
    public static void main(String[] args) {
Thread t=new Thread(()->{
    while(true){
    }
});//用getState()方法来获取当前线程的状态
        t.start();
        System.out.println(t.getState());
    }
}

输出结果:

线程的安全

c.TIMED_WAITING:这表示在排队等着其它的事情
代码中调用了sleep,就会进入这个状态,join(超时)。意思是当前线程在一定时间内是阻塞的状态

代码:

public class demo1 {
    public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
    while(true){
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
});
        t.start();
        t.join(1000);
        System.out.println(t.getState());
    }
}

运行结果:

线程的安全

d.BLOCKED:这几个都表示排着队等着其它事情
当前线程在等待锁,导致了阻塞

e.WAITING:这几个都表示排着队等着其它事情
当前线程在等待唤醒,导致了阻塞 

②状态转化的简易图:

线程的安全

2.线程的安全

2.1什么是线程的安全

①什么是线程的安全:

多线程里的安全问题的实质是是否存在bug的问题。
对于操作系统而言,由于线程的调度是随机的,因此就很可能会产生一些bug,而这些因为调度的随机性而引入的bug,我们就认为代码的线程是不安全的

②举一个线程不安全的例子:

使用两个线程,对同一整型变量进行自增的操作,每个线程自增5w次,我们看输出的最终结果。在理论上而言,输出的结果应该是5w+5w=10w次,但实际中是不是这样的呢?让我们一起来看看。

代码:

class Counter{
    public int count=0;
    void increase(){
        count++;
    }
}
public class demo1 {
    public static void main(String[] args) throws InterruptedException {
Counter counter=new Counter();
Thread t1=new Thread(()->{
    for(int i=0;i<50000;i++){
counter.increase();
    }
});
Thread t2=new Thread(()->{
    for(int i=0;i<50000;i++){
        counter.increase();
    }
});
t1.start();
t2.start();
t1.join();//必须要用join是因为不用join的话main线程是可以随意进行调度,值会更不准确,即都还没执行完相应的次数
t2.join();
        System.out.println(counter.count);
    }
}

输出结果:(我们发现这个时候结果并不等于10w,而且每次的数据值还是不一样的)

线程的安全

 我们就知道一定是出现了bug从而导致线程不安全。那为什么会出现这种情况呢?我们下面来进行分析。

③对②进行过剖析:(主要原因:抢占性执行)

count++到底干了什么?
站在CPU的角度来看,它一共执行了三条指令
a.把内存中的count值加到寄存器中
b.把寄存器中的值+1
c.把寄存器改变后的值存回到内存的count中

然而在这短短三条指令就会引发出很多种排列组合的情况,这里给大家罗列三种说明问题。

线程的安全

上述这两种情况是正常的操作,这个时候是没有出现bug的

线程的安全 而这种情况由于出现了抢占问题而导致了线程不安全的问题。就使得原本该自增2的结果只自增了1。

因此这就是线程不安全的一个例子,待会会在下文对其正确做法作出分析解答。 

2.2线程不安全的原因

1.线程是抢占性执行的。

因为线程之间的调度是充满随机性的(线性不安全的万恶之源)这是根本原因,但是我们却不能解决。

2.多个线程对同一变量进行修改操作。

上面我们举的那个例子就是这个问题(如果多个线程针对不同的变量进行修改,那么是没有问题的,如果是多个线程对同一个变量读,也是,没有问题的)

3.针对变量的操作不是原子性的。

这里的原子性之前在数据库事务这块也有提及,这两者是相似的。(读取变量值的这一操作是可以被看成是原子性的),而通过加锁操作,把好几个指令给打包成一个,这里也可以看作一个原子的操作

4.内存的可见性,也会影响到线程的安全。(比如:针对同一个变量,一个线程进行读操作可以循环进行很多次,而对一个线程进行修改操作,合适的时候执行一次)

线程的安全
我们以这个为例,对内存的可见性来进一步解释:

当t1这个线程开始循环读这个变量的时候,因为我们知道,读取寄存器的速度的数量级是读取内存的3~4倍,然而在这个时候,要是t2的值迟迟不改变的话,这个时候t1的值就是不变的,因此在这个时候,t1就想选择更高效的方式,即从寄存器中读取,那么这个时候,要是t2的值进行了修改,产生了变化,而t1并不能读到。而在java编译器中就有这种形式对代码进行优化,它是保证在原有逻辑不变的情况下来进行操作的,对于单线程而言是不会翻车的,但是由于多线程的调度充满了随机性,那么就很容易翻车,所以就导致了线程的不安全。

代码:

import java.util.Scanner;
class Counter{
   public static int isQuit=0;
}
public class demo1 {
    public static void main(String[] args)  {
        Counter counter=new Counter();
Thread t=new Thread(()->{
    while(counter.isQuit==0){

    }
    System.out.println("循环结束,t线程退出!");
});
t.start();
        Scanner scc=new Scanner(System.in);
        System.out.println("请输入isQuit的值");
        counter.isQuit=scc.nextInt();
        System.out.println("main线程执行完啦");
}
}

按道理来说,当输入的isQuit!=0时,不仅会执行main线程的输出语句,也会执行t线程的输出语句,然而由于这里的内存可见性,并没有输出t线程的输出语句,即内存上作出的修改,但是此时编译器读取的却是寄存器上的,并没有感知到内存上数据的变化。

结果如下:

线程的安全

如何进行修改,我们会在2.3进行讲解 

 5.指令重排序,也会影响到线程安全问题

指令重排序,也是编译器优化的一种操作。因为对于我们平时写代码而言,并不会刻意地去区分顺序。而编译器就会智能的调整这里代码的前后顺序来提高程序的效率。虽然也是在保证不变的前提下去调整顺序,但是对于多线程而言,编译器仍有很大的可能产生误判,从而产生bug,从而线程是不安全的。

2.3线程不安全的解决方案

这里主要是针对2.2 中的线程不安全的原因进行的解决。每点都是对应的

1.因为线程之间的调度是充满随机性的(线性不安全的万恶之源)这是根本原因,但是我们却不能解决

2.可以通过调节代码结构,使不同的线程操作不同的变量

3.通过加锁成为一个整体,而体现原子性。

这里就把我们在片头举的不安全的例子来改进。我们把每个线程的操作进行加锁,在自增前进行加锁,自增之后再进行解锁。比如,当t1已经把锁给占了,此时t2尝试lock就会发生阻塞,lock会一直阻塞,直到t1解锁后(执行了unlock),这个时候t2,才能进行lock;

加锁的方式有很多,我们最常用的是synchronized这样的关键字。进入此方法会自动加锁,结束后自动解锁。我们对代码进行改进

代码:

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

这个时候的图示:

线程的安全

这个时候的代码结果就是我们预期中的结果:

线程的安全  

4.针对内存的可见性,我们解决方案有两种,一是使用synchronized进行修饰,二十采用volatile来进行修饰。

synchronized不仅能够保证指令的原子性,也能够保证内存可见性,而volatile只能保证内存可见性,与原子性无关(关于详细的两者用法,将会在2.4,2.5提及)

利用volatile修改后的代码:

import java.util.Scanner;
class Counter{
    volatile public  static int isQuit=0;
}
public class demo1 {
    public static void main(String[] args)  {
        Counter counter=new Counter();
Thread t=new Thread(()->{
    while(counter.isQuit==0){

    }
    System.out.println("循环结束,t线程退出!");
});
t.start();
        Scanner scc=new Scanner(System.in);
        System.out.println("请输入isQuit的值");
        counter.isQuit=scc.nextInt();
        System.out.println("main线程执行完啦");
}
}

结果如下:

线程的安全

synchronized也可以到达这个效果(这里的synchronized是用于保证内存的可见性的)

代码:

import java.util.Scanner;
class Counter{
    int isQuit=0;
}
public class demo1 {
    public static void main(String[] args)  {
        Counter counter=new Counter();
        Thread t=new Thread(()->{
            while(true){
synchronized (counter){
   if(counter.isQuit!=0){
       break;
   }
}
            }
            System.out.println("循环结束");
        });
        t.start();
        Scanner scc=new Scanner(System.in);
        System.out.println("请输入isQuit的值");
        counter.isQuit=scc.nextInt();
        System.out.println("main线程执行完啦");
    }
}

5.对于指令重排序的解决方案,用synchronized关键字

2.4synchronized关键字

①含义:

synchronized的本意是同步的,而在计算机中,不同的板块中却是有着不同的含义。

比如:a.在多线程的线程安全中,同步就指的是“互斥”

b.在IO/网络编程中,此处的同步却是指的是异步,与a中的互斥以及线程是没有任何关系的。这里是表明消息的发送方,如何获取到消息。

②使用方式:

a.直接用于修饰普通方法:

代码:

class Counter{
    public int count=0;
 synchronized   void increase(){
        count++;
    }
}

使用synchronized的时候,本质上是在针对某个“对象”进行加锁,此时锁对象就是this,这里的加锁操作就是在设置this的对象头的标志位。

什么是对象头?

在Java中,每个类都是继承自object,每个new出来的实例,里面一方面包含了你自己安排的属性,另一方面包含了“对象头”,对象的一些元数据

线程的安全

对象头所在的区域可以理解成有人无人的情况,举个例子,一群滑稽在等待上厕所,这个时候,厕所里有一个滑稽,那么厕所外的这群滑稽就需要等厕所里的滑稽出来,才能再有人进入,对于厕所里的滑稽而言,就相当于锁住了,而对于厕所外的滑稽而言,就相当于被阻塞了,正在排队等待

线程的安全

 注意:

上一个线程解锁之后
,
下一个线程并不是立即就能获取到锁
.
而是要靠操作系统来
"
唤醒
".
也就是操作系统线程调度的一部分工作。
假设有
A B C
三个线程
,
线程
A
先获取到锁
,
然后
B
尝试获取锁
,
然后
C
再尝试获取锁
,
此时
B 和 C
都在阻塞队列中排队等待
.
但是当
A
释放锁之后
,
虽然
B

C
先来的
,
但是
B
不一定就能获取到锁,
而是和
C
重新竞争
,
并不遵守先来后到的规则。

b.用于修饰代码块:

需要显示指定针对哪个对象加锁(java中的任意对象都可以作为锁对象)

代码:

class Counter{
    public int count=0;
  void increase(){//这里是指锁当前的对象
      synchronized (this){
          count++;
      }
    }
}

 注意当两个线程针对同一个对象加锁时才会形成竞争,针对不同对象时是不存在竞争关系的

c.用于修饰静态方法:

是针对类对象来进行加锁的

(1)是直接在静态方法中修饰

class Counter{
    public static int count=0;
 synchronized public static void increase(){//这里是指锁当前的对象
          count++;
    }
}

(2)是直接在静态方法的代码块中修饰

class Counter{
    public static int count=0;
  public static void increase(){//这里是指锁当前的对象
        synchronized (Counter.class){
            count++;
        }
    }
}

线程的安全 以上的所有使用synchronized关键字在自增的操作中,结果均如下图所示:

线程的安全

③synchronized的特性:

a.互斥性:

synchronized
会起到互斥效果
,
某个线程执行到某个对象的
synchronized
中时
,
其他线程如果也执行到同一个对象 synchronized
就会
阻塞等待
.
进入 synchronized
修饰的代码块
,
相当于
加锁
退出
synchronized
修饰的代码块
,
相当于
解锁
线程的安全

这里用到的锁是存在于java对象头里的,关于对象头,我们上面已经讲过了,这里就不重复讲解了。

b.刷新内存
synchronized 的工作过程:
1. 获得互斥锁
2.
从主内存拷贝变量的最新副本到工作的内存
3.
执行代码
4.
将更改后的共享变量的值刷新到主内存
5.
释放互斥锁
c.可重入
1.什么是可重入锁:
synchronized
同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
关于可重入锁的理解,我们这里用一段代码来说明:
static class Counter {
    public int count = 0;
    synchronized void increase() {
        count++;
   }
    synchronized void increase2() {
        increase();
   }
}
increase

increase2
两个方法都加了
synchronized,
此处的
synchronized
都是针对
this
当前对象加锁的。在调用 increase2
的时候
,
先加了一次锁,按照我们开始所讲的,这个时候我们想执行increase2中的内容,就需要再次进行一次加锁,但是我们increase2并没有解锁,这就发生了矛盾。一方面,increase2没有解锁就要执行increase,另一方面要开启increase的锁。而在这里
又加了一次锁
. (
上个锁还没释

,
相当于连续加两次锁
)的这种操作就叫做可重入锁。
2.可重入锁的实质:
可重入锁的实质是在加入一把锁后,之后加入新的锁或者是解锁新的锁都只是在原来计数的count基础上进行加减。
3.可重入锁的意义:
提高了开发效率,但是降低了运行效率(但在实际工作中,我们更注重开发效率的提高)
④死锁的四个必要条件:
a.互斥性:
一个锁被一个线程占用了之后,其它线程就占用不了了(锁的本质,保证原子性)
b.不可抢占:
一个锁被一个线程占用了之后,其它线程是不能把这个锁给抢走的
c.请求和保持:
当一个线程占据了很多把锁之后,除非是显示释放锁,否则则这些锁始终都是被该线程所持有的
d.环路等待:
等待关系成了环,A等B,B等C,C又等A。而如何避免出现环路等待呢?只要约定好针对多把锁的时候有固定的顺序即可。所有的线程都遵守同样的规则顺序,就不会出现环路等待
线程的安全

 这里给大家举一个例子来解决这个问题:

1.出现环路等待的情况:

5个滑稽老铁围在一起吃面条,然后这个时候桌上只有5根筷子,我们此时规定每人先拿起自己左手边的筷子,后拿起自己右手边的筷子,我们会很容易地发现,当5个人左手都拿起了筷子的时候,这个时候每个人的右手却拿不了筷子,而造成的僵局则是,没有人能够吃到面条。这里就出现了环路等待的情况
线程的安全

2.解决环路等待的情况:

而如何解决呢?我们将筷子进行编号,然后规定每个人拿较小号数的筷子,我们会发现有个滑稽拿不了任何一根筷子,与此同时,有一个滑稽可以拿到一双筷子,而这个拿到一双筷子的滑稽吃完后,其余的滑稽就能陆陆续续吃到面条,这就打破了这个僵局。

2.5volatile 关键字

①特点:

禁止编译器的优化,保证“内存的可见性”

②如何来实现上述特点:

我们前面谈及过,要想执行一些运算,就需要把内存中的数据读到CPU寄存器中,然后在在寄存器中计算,最后再写入内存中。
而CPU访问寄存器的速度是访问内存的3~4个数量级,当CPU多次访问内存,发现结果都一样时,CPU就想偷懒
JMM java memory model(java内存模型)
JMM就是把我们所讲述的硬件结构,在java中用专门的术语又重新封装了一遍。
线程的安全

 由于CPU从内存中取数据实在是太慢了,尤其是在我们对其进行频繁操作的时候,要是我们直接把数据放到寄存器中,确实速度提升了很多,但是对于寄存器而言,空间是有限的,于是CPU又重新规划了空间,这个空间比内存小,比寄存器大,速度比内存快,比寄存器慢,我们称为cache(缓存)。

线程的安全

我们就可以将最常用的数据放在读取速度最快的位置,然后以此类推 

注意!!!虽然内存的可见性也可以用synchronized来进行解决,但是synchronized并不是万能的,synchronized很容易引起线程的阻塞,一旦线程发生阻塞,就不好控制时间了。所以这个更是推荐用过volatile关键字

2.6Java 标准库中的线程安全类

①Java
标准库中很多都是线程不安全的
这些类可能会涉及到多线程修改共享数据
,
又没有任何加锁措施。
ArrayList、LinkedList 、HashMap、TreeMap、HashSet、TreeSet 、StringBuilder
②一些是线程安全的。
a.使用了一些锁机制来控制.
Vector (
不推荐使用
) 、HashTable (不推荐使用
) 、ConcurrentHashMap 、StringBuffer
b.还有的虽然没有加锁
,
但是不涉及
"
修改
",
仍然是线程安全的
String

2.7wait 和 notify

①引入原因:

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知。但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。

完成这个协调工作, 主要涉及到三个方法

wait() / wait(long timeout):
让当前线程进入等待状态
.
notify() / notifyAll():
唤醒在当前对象上等待的线程
.
②wait方法:
a.wait方法运行的过程:
(1)使当前执行代码的线程进行等待. (
把线程放到等待队列中
)
(2)释放当前的锁
(3)满足一定条件时被唤醒
,
重新尝试获取这个锁
.
wait 要搭配 synchronized 来使用。因为它的运行方式是释放当前的锁,所以在此之前,这里的对象必然是处于锁中的,所以要搭配synchronized来使用。脱离 synchronized 使用 wait 会直接抛出异常。

不搭配synchronized使用时的代码:
public class demo3 {
    public static Object object=new Object();
    public static void main(String[] args) {
        Thread t=new Thread(()->{
            System.out.println("wait前");
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("wait后");
        });
        t.start();
    }
}

运行结果,就会抛出异常

线程的安全

 搭配synchronized使用时的代码:

public class demo3 {
    public static Object object=new Object();
    public static void main(String[] args) {
Thread t=new Thread(()->{
   synchronized (object){
       System.out.println("wait前");
       try {
           object.wait();
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
       System.out.println("wait后");
   }
});
        t.start();
    }
}

运行结果:

线程的安全

但是却没有wait后,这是因为代码在执行wait的时候进行了阻塞,而我们需要用notify来对其进行唤醒,而从而获取这个锁。 

b.
wait
结束等待的条件
:
其他线程调用该对象的
notify
方法
.
wait
等待时间超时
(wait
方法提供一个带有
timeout
参数的版本
,
来指定等待时间
).
其他线程调用该等待线程的
interrupted
方法
,
导致
wait
抛出
InterruptedException
异常
.
调用了notifty方法后的代码:
public class demo3 {
    public static Object object=new Object();
    public static void main(String[] args) {
Thread t1=new Thread(()->{
   synchronized (object){
       System.out.println("wait前");
       try {
           object.wait();
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
       System.out.println("wait后");
   }
});
        t1.start();
        Thread t2=new Thread(()->{
          synchronized (object){
              System.out.println("notify前");
              object.notify();
              System.out.println("notify后");
          }
        });
        t2.start();
    }
}

结果如下:

线程的安全

③notify()和notifyAll()方法:

a.notify:(wait,notify都是针对同一个对象来进行操作的)
如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait
状态的线程。
(
并没有
"
先来后到
")

notify()
方法后,当前线程不会马上释放该对象锁,要等到执行
notify()
方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。
b.notifyAll:
notify方法只是唤醒某一个等待线程.
使用
notifyAll
方法可以一次唤醒所有的等待线程。但唤醒后尝试获取锁的时候就会发生竞争
这里举个例子对上述两个概念进一步说明:
比如现在有一个对象o,有10个线程都调用o.wait,那么此时10个状态都会是阻塞状态。如果这个时候调用了o.notify(),就会唤醒把10个中的一个线程唤醒(任意的)

要是这个时候调用的是o.notifyAll()就会把10个线程都唤醒,wait唤醒后会尝试重新获取锁,这个时候也就会发生竞争关系。

④wait和sleep的对比:

其实理论上
wait

sleep
完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。
当然为了面试的目的,我们还是总结下:
1. wait
需要搭配
synchronized
使用
. sleep
不需要
.
2. wait

Object
的方法
sleep

Thread
的静态方法
.

感谢观看~线程的安全

版权声明:程序员胖胖胖虎阿 发表于 2022年10月8日 上午6:32。
转载请注明:线程的安全 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...