线程安全~

什么是线程安全?

线程安全是我们多线程的核心问题 ~ ,也是多线程中很难驾驭的一个难点,
我们来举个"栗子":

public class ThreadDemo11 {
    static class Counter{
        public int count = 0;
        public void increase(){
            count++;
        }
    }
    static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        //创建两个线程,分别自增五万次
        Thread t1= new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };

        Thread t2= new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
        
    }
}

我们运行上述的代码后就会发现,结果并不是10万 ! ~, 而且每次的运行结果都是不相同的 ~
我们可以把它当成一个BUG来看待,那么这种情况是如何产生的呢?
我们可以猜到大概率是并发编程而引起的 ~ ,
这种情况我们就视为"线程不安全" ~
我们来分析一下上述的执行过程~
在上述代码中的自增函数increase() ,它在上述线程中执行时,一直是循环的自增,来实现count++;
count++ 在计算机中的实际步骤可以分为三步:
1.从内存中读取count 到CPU中~(LOAD)
2.CPU实现++操作~(ADD)
3.将CPU中的数据又写回内存中(SAVE)
我们可以看到count++的操作步骤就有三步,再之前的文章中我说过线程的并发执行是一种"抢占式执行",在两个线程并发执行的时候他们会抢占资源来执行,执行的过程可能就是以下这几种情况
在这里插入图片描述
在这种情况下,我们可以知道两个线程的执行并不会对结果产生不好的影响,可以得到我们对应的结果,但是线程的执行不只是这种情况,还有可能是
在这里插入图片描述
如果是上图这种情况我们就会发现,假如,这时候count = 1,

当线程1执行LOAD后,并没有立刻被执行线程1的ADD等操作,而是线程2开始执行对应操作,线程2操作后将自增的结果写回内存中,内存中count变为了2,

之后线程1的ADD开始执行,这时候线程1执行的ADD还是线程1读到的CPU中的数据count = 1,然后进行自增以及写回内存中,内存中的count最后还是2,

我们就可以发现,线程1,2各自执行了一次后,count只自增了1个,而在系统内核中的线程执行操作不是我们能说了算的,具体怎么执行还要看操作系统自己的断定,这样的随机性就导致了我们最后得到的结果与我们的预期并不相同.
(只用串行操作时候,才不会出现这种情况的~)

上述代码的情况就是我们所说的"线程不安全"~~

线程不安全的原因

根据上面的例子,我们可以得到线程不安全产生的原因有几种:

1~ 线程之间是 “抢占式执行” 的 , 这样的执行方式导致了我们并不能知道线程执行顺序的先后,如此大的随机性也就导致了我们"线程不安全"问题的发生~

2~ 多个线程修改同一个变量~
当两个线程修改同一个变量时,就如上文中的代码一样,会出现相同情况,出现问题

3~ 原子性~
像刚刚的count++操作,本质上就是三个步骤,这就是一个"非原子性"的操作,这种操作可能就会导致问题的产生 , 像 = 这种操作本质上就是一个赋值操作,就是一个步骤,"原子"的操作就不会产生"线程不安全"的困扰~

4 ~内存可见性
这种是与原子性相似,主要是与编译器的优化有关~
例子:
线程1,读取变量
线程2,对变量循环自增~
很多次自增就会涉及很多次LOAD和SAVE,CPU执行ADD速度比另外两个速度快一万倍,为了提高程序的整体效率,线程2,就会把中间的LOAD和SAVE操作省略掉,连续执行很多次ADD,这个省略操作是编译器(javac)和JVM(java)综合配合达成的效果 ,这种线程一的LOAD读不到SAVE,读到的就是0 !~
因此,一个线程修改,一个读取,由于编译器的优化,
读到的可能就是未修改过的结果~.

5 ~ 指令重排序

编译器在运行时,会自动的调整指令执行的顺序,来达到更高的效率,如果是单线程的话,这种操作就不会对结果产生影响,但是多线程下,这个就无法保证了编译器判断的顺序在逻辑上是否准确~

如何解决"线程不安全"的问题

代码中如果"线程不安全"那么带来的问题将会影响非常之大,那我们如何来解决这些问题呢?

1 . synchronized

我们可以通过"加锁"的方式让代码保持"原子性",来达到"线程安全"的目的~

我们通常使用synchronized 关键字来进行"加锁"
我们来对上面的例子进行修改

static class Counter {
        public int count = 0;
        //加锁
        synchronized  public void increase(){
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
      Counter counter = new Counter();
      Thread t1 = new Thread(){
          @Override
          public void run() {
              for (int i = 0; i < 50000; i++) {
                  counter.increase();
              }
          }
      };
        Thread t2 = new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
    t1.start();
    t2.start();
    t1.join();
    t2.join();
        System.out.println(counter.count);
    }
    //运行结果
    100000

通过在increase()前用synchronized来修饰它达到"加锁"的效果,让这个线程1先执行这个方法,另一个线程2等待,直到线程1执行结束,线程2才会开始执行,这样就避免了线程之间的"抢占"~

我们也会发现,这种方式会使我们的代码达到我们的需求,但同时效率也会降低~~

上述代码的increase方法可以改为:
public void increase(){
    synchronized(this) 
  {
    count++;
  }
}
.这种也可以达到对应的效果

在上文中我们用的"",存在于java的对象头中,
在这里插入图片描述
对象头中会有一个对应的"锁标记",
我们的"加锁",就是把这个锁对象中的锁标记变为"true";
我们的"解锁",就是把这个锁标记变为"false";
注意:java中,所有的对象都可以是"锁对象"

synchronized还能够刷新我们的内存,来解决内存可见性问题

synchronized会禁止自增过程中的优化,保证每次的读写操作都真的能从内存中读,并且写回到内存中~(使得效率降低,准确性增强)

synchronized 可重入~
允许一个线程对一把锁二次进行"加锁",
例如~

synchronized public void increase(){
    synchronized(this) 
  {
    count++;
  }
}

这样的操作在java中也是允许的~

synchronized 记录了当前的"锁"被哪个线程所持有~

在这里我们就可以总结下synchronized具有的三个特性~
1.互斥(让代码具有"原子性",串行执行)
2.刷新内存(解决内存可见性问题)
3.可重入(防止不小心代码写错,对线程重复加锁而造成"死锁")
需要注意的是:
synchronized对普通方法进行修饰,就是针对this进行"加锁",两个线程的并发执行会不会出现"锁竞争",就要看两个线程需要加锁的是不是同一个对象~
而synchronized对静态方法进行修饰,就是对类对象(.class可以获取当前类的对象信息)进行加锁,因为类对象是单例的,所以两个线程并发的调用这个方法,就一定会触发"锁竞争"~

2 . volatile

解决"线程安全"问题我们也可以使用volatile关键字
可以解决内存可见性问题,但不能保证原子性~

static class Counter{
        public int flag = 0;
    }
    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread t1 = new Thread()
        {
            @Override
            public void run() {
                while(counter.flag == 0)
                {
                    //执行某些任务
                }
                System.out.println("循环结束");
            }
        };
        t1.start();

        Thread t2 = new Thread()
        {
            @Override
            public void run() {
                Scanner scanner = new Scanner(System.in);
                System.out.println("输入一个整数");
                counter.flag = scanner.nextInt();
            }
        };
        t2.start();
    }

上述的代码运行后输入非0的数也并不会使程序停止~,主要原因是上述代码中循环的读取flag(频繁),编译器就对此进行了优化,后续直接从CPU中读取,而不是内存中,而线程2对内存中的flag进行了修改,但是线程1读不到flag的新数据,就带来了这样的问题 ~
我们就可以使用volatile来解决~
在上面的Counter类中,将里面的成员变量改为被volatile修饰的,

volatile public int flag = 0;

就可以解决我们的问题~

注意 : 一般来说,对于一个变量来说,一个线程读,一个线程写,则会用到volatile~

总结

在上述中我们谈到了"线程不安全"所带来的后果,以及什么原因造成了这样的问题,也给出了具体的解决办法(synchronized \ volatile),关于线程安全,我们还需要更加的去重视和探究 ~~~~ 在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值