简述线程安全问题的原因和解决方案

文章目录

  • 一、线程安全
  • 二、线程不安全的原因
    • 2.1 修改共享数据
    • 2.2 原子性
    • 2.3 可见性
    • 2.4 代码顺序性
  • 三、解决方案
    • 3.1 synchronized关键字
      • 3.1.1 synchronized的特性
      • 3.1.2 Java标准库中的线程安全类
    • 3.2 volatile关键字
    • 3.3 wait和notify
      • 3.3.1 wait()方法
      • 3.3.2 notify()方法
      • 3.3.3 notifyAll()方法
    • 3.4 wait和sleep的对比

一、线程安全

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

二、线程不安全的原因

2.1 修改共享数据

简而言之就是多个线程对同一个变量进行修改。

2.2 原子性

什么是原子性?举个例子,我们把每段代码想象成一个厕所,每个线程就是要进入厕所的人。如果没有任何保障机制,A进入厕所,还没有出来;B这时进入厕所就会打断A上厕所。这个就是不具备原子性的。

为了解决上述问题,我们是不是应该给厕所加上一把锁,A进去之后,就把们锁上,其他人进就不能在A没有出来之前进入,这样就保证了这段代码的原子性。

有时候也把这个现象叫做同步互斥,表示操作是互相排斥的。

一条java语句不一定是原子的,也不一定只是一条指令

比如在代码中,我们执行n++操作,其实这代码是由三步操作组成的:

  1. 从内存把数据读到CPU
  2. 进行数据更新
  3. 把数据写回到CPU

不保证原子性会给多线程带来什么问题呢?如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,那么执行的结果就很有可能是错误的。

2.3 可见性

可见性指的是一个线程对共享变量值的修改,能够及时地被其他线程看到。

Java 内存模型 (JMM):

  • 线程之间的共享变量存在主内存
  • 每一个线程都有自己的“工作内存
  • 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据
  • 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存

2.4 代码顺序性

今天逛超市需要买以下几样东西:

  1. 西瓜
  2. 大米(在超市出口)
  3. 蔬菜(在超市入口)

如果是单线程情况下,JVM、CPU指令集会对其进行优化,比如按照3->2->1的方式执行,也是没有问题的,可以节约更多的时间。这种叫指令重排序。

编译器对于指令重排序的前提是“保持逻辑不发生变化”,这一点在单线程环境下比较任意判断,但是在多线程环境下就没有那么容易了,多线程的代码执行复杂度更高,编译器很难在编译阶段对代码执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价

三、解决方案

3.1 synchronized关键字

3.1.1 synchronized的特性

  1. 互斥

    synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到了同一个对象,synchronized就会阻塞等待。

    a.进入synchronized修饰的代码块,相当于加锁
    b.退出synchronized修饰的代码块,相对于解锁
    

    synchronized的底层是使用操作系统的mutex lock实现的。

  2. 刷新内存

    synchronized的工作过程:

    1.获得互斥锁
    2.从主内存拷贝变量的最新副本到工作内存
    3.执行代码
    4.将更改后的共享变量的值刷新到主内存
    5.释放互斥锁
    
  3. 可重入

    synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。

    简单的理解可重入就是,一个线程进入一个锁并释放后,可以重新进入这个锁,并再次释放。
    

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

Java标准库中很多线程都是不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施。

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

以下是一些线程安全的,使用了一些锁机制:

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

3.2 volatile关键字

volatile和synchronized有着本质的区别,synchronizd既能保证原子性,也能保证内存可见性;但是volatile不保证原子性,只保证内存可见性。

3.3 wait和notify

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

3.3.1 wait()方法

wait做的事情:

  • 使当前执行代码的线程进行等待(把线程放到等待队列中)
  • 释放当前锁
  • 满足一定条件时被唤醒,重新尝试获取这个锁

wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.。

wait结束等待的条件:

  • 其他线程调用该对象的notify方法
  • wait等待时间超过
  • 其他线程调用该线程的interrupted方法,导致wait抛出InterruptedException异常
public static void main(String[] args) throws InterruptedException {
    Object object = new Object();
    synchronized (object) {
        System.out.println("等待中");
        object.wait();
        System.out.println("等待结束");
   }
}

这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。

3.3.2 notify()方法

notify方法时唤醒等待的线程:

  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其他线程,对其发出通知notify,并使他们重新获取该对象的对象锁
  • 如果有多个线程等待,则有线程调度器随机挑选一个是wait状态的线程
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁

3.3.3 notifyAll()方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程

3.4 wait和sleep的对比

其实理论上,wait和sleep完全是没有可对比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间的,唯一的相同点就是都可以让线程放弃执行一段时间。

总结:

  1. wait需要搭配synchronized使用,sleep不需要
  2. wait是Object的方法,sleep是Thread的静态方法

你可能感兴趣的:(JavaEE,java,jvm,面试,javaee,安全)