线程安全问题

目录

什么是线程安全

为什么会存在线程安全问题 

JAVA内存模型——JMM

如何保证线程安全

synchronized

互斥性

刷新内存

 可重入

synchronized的使用

volatile

保证共享变量的可见性

volatile作为内存屏障

final保证可见性

Java标准库的线程安全类 

什么是线程安全

线程安全就是在多线程并发情况下执行的结果跟单线程下顺序执行的预期结果不符的问题,这种现象就是线程不安全问题

package thread;

public class ThreadUnsafe {
    private static class count{
        int count=0;
        void increase(){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        count c1=new count();
        Thread t1=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    c1.increase();
                }
            }
        });
        Thread t2=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    c1.increase();
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(t1.getState());
        System.out.println(t2.getState());
        System.out.println("两个搬砖人已经工作结束,一共板砖"+c1.count+"块");
    }
}

为什么会存在线程安全问题 

JAVA内存模型——JMM

JMM 和 JVM部分讲到JVM内存区域划分(JVM实实在在将内存划分为6大区域),而JVM只是用来描述线程工作内存和主内存的关系(就是多线程下的JAVA线程内存(CPU的高速内存和寄存器)和主内存的关系)

  • 每个线程都有自己的工作内存,每次读取变量(共享变量,不是线程的局部变量,比如方法的局部变量和run方法的局部变量是线程私有),共享变量都是存在主内存中
  • 共享变量(类中成员变量,静态变量,常量都属于共享变量,在堆和方法区中存储的变量)
  • 关于修改共享变量,每次都是先从主内存将变量加载到自己的工作内存,之后关于此变量的所有操作都是在自己的工作内存中进行,然后写回主内存
  • 如果t1线程已经将主内存的变量的写入工作内存,如果此时其他线程t2已经将主内存的变量写入内存,那么对于t2来说,t1修改后的值就是不可见的,或者如果没有将主内存的变量写入内存,此时从主内存读的值是还没有更新的值(脏读)

线程之间如何通信

  • 通信是指线程之间以如何来交换信息。一般线程之间的通信机制有两种:共享内存和消息传递。Java的并发采用的是共享内存模型(JMM),Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

什么是Java内存模型

  • java内存模型简称jmm,定义了一个线程对另一个线程可见。共享变量存放在主内存中,每个线程都有自己的本地内存,当多个线程同时访问一个共享数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全问题。
     

如何在两个线程间共享数据
在两个线程间共享变量即可实现共享。


Java 如何实现多线程之间的通讯和协作

  • 可以通过中断 和 共享变量的方式实现线程间的通讯和协作

比如说最经典的生产者-消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去。因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,并进入挂起状态。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是线程间的协作。
Java中线程通信协作的最常见方式:

  • 一.syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll()
  • 二.ReentrantLock类加锁的线程的Condition类的await()/signal()/signalAll()


 

因为操作的非原子性和属性的不可见性和防止指令重排导致的线程不安全

保证三大特性 原子性 可见性 防止指令重排

原子性

该操作对应CPU的一条指令,这个操作不会被中断,要么全部执行,要么都都不执行,不会存在中间状态

原子性的超卖现象

 客户端A先卖了一张票,然后去判断B的,由于A没将卖掉的结果写入主内存,所以客户端B的显示还有票,然后又卖了一次,那么执行A的将卖票的结果写入主内存,也执行B的卖票的结果,所以一张票就被卖了两次,称为超卖现象

可见性

一个线程对共享变量的的修改,能够及时的被其他线程看到,这种特性称为可见性,(可见性可以用final,volatile和synchronized上锁来保证)

 

  • 在多线程的执行下,就存在各种各样的执行的可能性,执行的结果充满了各种的可能性,这种可能性是不确定,是可能是由原子性和可见性的原因导致每次的执行结果不一样

指令重排

代码的书写顺序不一定就是JVM或者CPU最终的执行顺序

  • 在单线程的上述的重排没有关系,但是在多线程下就会有很大的问题 
  • 指令重排了解即可,因为很少是因为指令重排导致的线程安全问题

什么是重排序

  • 程序执行的顺序按照代码的先后顺序执行。一般来说处理器为了提高程序运行效率,可能会对输入代码进行优化,进行重新排序(重排序),它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

as-if-serial规则和happens-before规则

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。


 

如何保证线程安全

我们只需要保证线程的原子性,防止指令重排,和可见就可以防止线程安全的发生

synchronized

  • 用synchronized可以保证线程的原子性和可见性

定义:就是监视器锁,monitor lock(对象锁),锁的是资源

互斥性

synchronized 会起到互斥效果 , 某个线程执行到某个对象的 synchronized 中时 , 其他线程如果也执行到 同一个对象 synchronized 就会 阻塞等待 .,处于阻塞等待状态
  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
  •  synchronized用的锁是存在Java对象头里的。

 synchronized的上锁就拿上厕所举例子,四个滑稽想上厕所,厕所是一种资源,我们怎么才能大胆安心的去上厕所呢?要保证厕所是安全的,我们就给厕所上锁,让厕所是安全的,每次只能一个人占用厕所,别人进不来,在上厕所的时候上锁(进入synchronized代码块)(此时其他三个人就处于该资源的阻塞等待状态),上完厕所解锁(执行完synchroized代码块)


public class ThreadUnsafe {
    private static class count{
        int count=0;

        synchronized void increase(){
            count++;
        }

    }

    public static void main(String[] args) throws InterruptedException {
        count c1=new count();
        Thread t1=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    c1.increase();
                }
            }
        });
        Thread t2=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    c1.increase();
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(t1.getState());
        System.out.println(t2.getState());
        System.out.println("两个搬砖人已经工作结束,一共板砖"+c1.count+"块");
    }
}

  •  正因为increase方法上锁处理,多个线程在执行increase方法时候其实是排队进入,同一个时刻只可能有进入一个线程进入increase方法执行对count属性的操作
  • 比如t1先执行了increase方法,那么t1就会获取counter这个对象的锁,然后执行increase方法,t2如果想执行这个increase方法会处于阻塞态,就必须等到t1释放锁之后,才能执行

锁的数据结构

在JAVA内部,每个JAVA对象都有一块内存,描述当前对象“锁”的信息,锁的信息就表示当前对象被哪个线程持有

  • 若锁信息没有保存线程,说明该对象没有被任何线程持有
  • 若锁的信息保存了线程id,其他线程要获取该锁,就处在阻塞状态
  • synchronized的底层是使用操作系统的mutex lock实现的
     

 要注意锁的对象不同

package thread;

public class ThreadUnsafe {
    private static class count{
        int count=0;

        synchronized void increase(){
            System.out.println(Thread.currentThread().getName()+"获得锁");
            count++;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }

    public static void main(String[] args) throws InterruptedException {
        count c1=new count();
        Thread t1=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    c1.increase();
                }
            }
        },"t1");
        Thread t2=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    c1.increase();
                }
            }
        },"t2");
        count c2=new count();
        Thread t3=new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    c2.increase();
                }
            }
        },"t3");
        t1.start();
        t2.start();
        t3.start();
        t1.join();
        t2.join();
        t3.join();
        System.out.println("两个搬砖人已经工作结束,一共板砖"+c1.count+"块");
    }
}

 

  •  因为t1和t2有竞争关系,其t1和t2与t3没有竞争关系

总结什么时候锁的是什么

  • 就算是调用不同的上锁的静态方法,也会阻塞
  • 同一个对象调用不同的上锁成员方法 ,就会阻塞

刷新内存

synchronized的工作流程

  1. 获取对象锁
  2. 从主内存拷贝变量到工作内存
  3. 执行代码
  4. 将更改后的值写入主内存
  5. 释放对象锁

因为其1-5都是只有一个线程能执行,所以其2-4对于其他线程就是天然的可见性和原子性

 可重入

  • JAVA的线程安全锁都是可重入的(包括java.concurrent.lock)
  • 可重入的意思就是获取的对象锁的线程可以再次加锁

  •  按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁.
  • 可重入锁可以防止这种死锁问题

synchronized支持线程的可重入

  1. Java中每个对象都有一个对象头(描述当前对象的锁信息,当前对象被哪个线程所拥有,以及一个计数器——当前对象被上锁的次数)
  2. 若线程1需要进入当前对象的同步代码块(sychronized),此时当前对象的对象头没有锁信息

synchronized可重入的原理
重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。
 

synchronized的使用

synchronized是对象锁,所以必须要有具体的对象让他锁

直接修饰类的成员方法

  • 当前这个方法是通过谁调用的,synchronized就锁哪个对象 

直接修饰类的静态方法

  •  用来修饰类的静态方法,锁的是当前这个类的class对象(任何一个类的class对象全局唯一),描述该类的核心信息(具备那些属性和方法),这个对象是反射的核心对象

直接修饰代码块

  • 明确锁的对象
  • 锁的粒度更细,只有再需要同步的若干代码块上加上synchronized关键字

  • this表示当前对象的引用 ,如果不同的线程调用对象是不同的,那么不同线程之间是不互斥的(所争的资源是不一样的),如果不同线程调用对象是相同的,那么不同线程之间是互斥的

  • Reentrant.class表示这个类的唯一的class对象,只要是调用这个类的对象,都会对着个代码块进行互斥访问 

互斥与否,就看多个线程锁的到底是什么?

package thread;

public class ThreadLock {
    private static class Counter {
    int val;
    Object lock;
    void increase() {
        //不需要同步的代码
        //需要同步的代码 synchronized(任意对象,传啥锁啥)
        synchronized (lock) {
            while (true) {
                System.out.println(Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

}
    public static void main(String[] args) {
        Object lock=new Object();
        Counter c1=new Counter();
        c1.lock=lock;
        Counter c2=new Counter();
        c2.lock=lock;
        Counter c3=new Counter();
        c3.lock=new Object();
        Thread t1=new Thread(()->{
            c1.increase();
        },"t1");
        Thread t2=new Thread(()->{
            c2.increase();
        },"t2");
        Thread t3=new Thread(()->{
            c3.increase();
        },"t3");
        t1.start();
        t2.start();
        t3.start();
    }
}
  • t1和t1构成互斥,因为锁的lock是同一个对象
  • t1和t3 t2和t3都不互斥,因为锁的不是同一个对象

  • 如果锁的是静态方法,就算不同的线程调用不同的静态方法,也是互斥的 

同步方法和同步块,哪个是更好的选择
同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。同步块更要符合开放调用的原则,只在需要锁住的代码块锁住相应的对象,这样从侧面来说也可以避免死锁
 

synchronized关键字最主要的三种使用方式

  • 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员。如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁
  • 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

volatile

保证共享变量的可见性

volatile关键字可以保证共享变量可见性,相较于普通的共享变量,使用volatile关键字可以保证共享变量的可见性

  • 当线程读取的是volatile关键字时,线程直接从主内存中读取该值到工作内存中(无论当前工作内存中是否已经有该值)
  • 当线程写的是volatile关键字变量,将当前修改后的变量值(工作内存中)立即刷新到主内存,且其他正在读此变量的线程会等待(不是阻塞),直到写回主内存操作完成,保证读的一定是刷新后的主内存值
package thread;

import java.util.Scanner;

public class ThreadVolatile {
    private static class Counter{
        volatile int flag=0;
    }

    public static void main(String[] args) {
        Counter counter=new Counter();
        Thread t1=new Thread(new Runnable() {
            @Override
            public void run() {
                while (counter.flag==0){

                };
                System.out.println(counter.flag+"退出循环");
            }
        });
        t1.start();
        Thread t2=new Thread(new Runnable() {
            @Override
            public void run() {
                Scanner scanner=new Scanner(System.in);
                System.out.println("请改变flag的值");
                counter.flag=scanner.nextInt();
            }
        });
        t2.start();
    }
}

  •  volatile保证其可见性,无法保证原子性,因此若代码不是原子性操作,任然不是线程安全的
  • yield和sleep会导致线程让出CPU,当线程再次调度回CPU,有可能会重新读主存(JVM规范明确表示,yield和sleep方法不一定会强行刷新工作内存,读取主存,但是volatile会强行刷新内存)

volatile作为内存屏障

Java 中能创建 volatile 数组

  • 能,Java 中可以创建 volatile 类型数组,不过只是一个指向数组的引用,而不是整个数组。意思是,如果改变引用指向的数组,将会受到 volatile 的保护,但是如果多个线程同时改变数组的元素,volatile 标示符就不能起到之前的保护作用

volatile 变量和 atomic 变量有什么不同

  • volatile 变量可以确保先行关系,即写操作会发生在后续的读操作之前, 但它并不能保证原子性。例如用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性的。
  • 而 AtomicInteger 类提供的 atomic 方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。
     

synchronized 和 volatile 的区别是什么

  • synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。
  • volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。

区别

  • volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。
  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
     

final保证可见性

final的常量一定是可见的,因为常量必须在定义时赋值,且赋值后无法修改

happen-before

  • 如果⼀个操作happens-before另⼀个操作,那么第⼀个操作的执⾏结果将对第⼆个操作可⻅,⽽且第⼀个操作的执⾏顺序排在第⼆个操作之前。
  • 2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执⾏。如果重排序之后的执⾏结果,与按happens-before关系来执⾏的结果⼀致,那么JMM也允许这样的重排序。

与程序员密切相关的happens-before规则如下

  • 1、程序顺序规则:一个线程中的每个操作,happens-before于线程中的任意后续操作。
  • 2、监视器锁规则:一个锁的解锁,happens-before于随后对这个锁的加锁。
  • 3、volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 4、传递性:如果A happens-before B,且Bhappens-before C,那么Ahappens-before C。
     

并发编程三个必要因素是什么

  • 原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
  • 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
  • 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
     

如何保证线程安全

  • 线程切换带来的原子性问题 解决办法:使用多线程之间同步synchronized或使用锁(lock)。
  • 缓存导致的可见性问题(JMM模型) 解决办法:synchronized、volatile、LOCK,final可以解决可见性问题
  • 编译优化带来的有序性问题 解决办法:Happens-Before 规则可以解决有序性问题,volatile可以提供内存屏障

在 Java 程序中怎么保证多线程的运行安全

  • 方法一:使用安全类,比如 java.util.concurrent 下的类,使用原子类AtomicInteger
  • 方法二:使用自动锁 synchronized。
  • 方法三:使用手动锁 Lock。
     

Java标准库的线程安全类 

不安全的类

  • 多线程并发修改同一个集合的内容,就有数据安全问题

 安全的类

  • Vector和HashTable的读写操作都是单线程的
  • ConcurrentHashMap锁的是哈希桶对象
  • CopyOnWriteArrayList:线程安全的List集合,采用读写分离的机制,多线程并发读,互斥写 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

库里不会投三分

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值