多线程环境下Java如何实现线程安全

2021/7/23 12:07:19

本文主要是介绍多线程环境下Java如何实现线程安全,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

线程安全

    • 前言
    • 多线程环境下面临的风险
      • 分析造成线程安全问题
      • 多线程不安全的原因
    • 解决线程安全问题的方法
      • synchronized关键字
        • synchronized的具体操作
        • synchronized底层实现原理
        • 两个monitor标志的原因
        • synchronized可重入锁的原理
        • 自旋
        • synchronized锁升级底层的原理
      • CAS
        • 概念
        • CAS会产生什么问题?
      • Lock锁
        • Lock接口
        • Lock接口api
        • Lock实现的子类
          • AQS概念
          • AQS原理分析
          • AQS资源共享的方式
          • ReentrantLock(可重入锁)
          • ReadWriteLock(读写锁)
      • ThreadLocal
        • ThreadLocal应用实例
        • ThreadLocal的底层原理
      • 线程池
        • 线程池的主要执行流程
        • Executors类创建的四种常见线程池
        • ThreadPoolExecutor来创建线程池
        • 线程池的使用示例
      • 死锁
        • 死锁示例
        • 造成死锁的四个必要条件
        • 避免死锁的方法
        • 死锁与活锁的区别

前言

本篇博客将根据现有知识对Java多线程做以小结,以下博客仅作为个人学习过程的小结,如能对各位博友有所帮助不胜荣幸。
本篇博客将简单介绍线程安全的概念,以及实习线程安全的几种方法,重点叙述了几种常用的加锁操作,后期随学习深入还会补充修改。

多线程环境下面临的风险

之前介绍到,多线程的引入很大的提高了一个任务执行的效率,但另一方面在提高效率的同时也引入了一些风险
例如下面这个场景

//利用多线程,对同一个静态变量做++操作
public static int COUNT = 0;
    public static void main(String[] args) {
        Thread[] threads = new Thread[20];//创建一个线程数组同于存放接下来创建的线程
        for(int i = 0; i < threads.length; i++){
            threads[i] = new Thread(new Runnable() {//使用匿名内部类创建线程
                @Override
                public void run() {
                    for(int i = 0; i < 1000;i++) {
                        COUNT++;
                    }
                }
            });
        }

        for(Thread t : threads){//依次启动这20个线程
            t.start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();//保证Thread数组中的所有线程都跑完
        }
        
        System.out.println(COUNT);
    }

在这里插入图片描述
上述代码是在多线程环境先实现对同一个静态变量进行++操作
一共创建20个线程,每个线程执行 COUNT++ 1000次,理论上最终当这20个线程全部执行完毕,COUNT的值会变为20000
实际上经过三次执行,COUNT的值都未达到20000,并且每次的值都不相同,为什么?

分析造成线程安全问题

对于上述代码,其在启动后经历了如下流程:
启动 ——> 执行java.exe进程 ——> 初始化JVM参数 ——> 创建JVM虚拟机 ——> 启动后台线程 ——> 启动java级别的main线程(开始执行java中 main方法)
在这里插入图片描述
当thread线程开始执行run方法的COUNT++时,会拆解成三步执行

  1. 从方法区中获取到COUNT
  2. 将COUNT的值在工作内存中修改为COUNT+1
  3. 将COUNT的值写回到主内存中

风险:此时因为会有20个Thread线程同时执行,有可能就会出现,两个线程的工作内存同时获取到COUNT(即获取到的COUNT值相同),那么此时两线程执行结束写回主内存后,COUNT的值只是+1并没有达到预期的+2,此时就出现了线程安全。
在这里插入图片描述
导致线程安全的根本原因:多个线程对同一段内存进行修改重新写入,导致修改的内容无法一定被真正修改

多线程不安全的原因

  1. 原子性
    多行指令,如果指令前后有依赖关系,不能插入其他影响自身线程执行结果的指令
  2. 可见性
    系统调用CPU执行线程内,一个线程对共享变量的修改,另一个线程能够立刻看到
  3. 有序性
    程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序)

解决线程安全问题的方法

synchronized关键字

在Java中,synchronized关键字用来控制同步线程,多线程环境下,synchronized修饰的代码块、类、方法、变量不能被线程同时执行,

在 jdk1.6 以前 synchronized 的 java 内置锁不存在 偏向锁 -> 轻量级锁 -> 重量级锁 的锁膨胀机制,
锁膨胀机制是 1.6 之后为了优化 java 线程同步性能而实现的。而 1.6 之前都是基于 monitor 机制的重量级锁。

synchronized的具体操作

synchronized关键字加到静态方法和代码块上都是给该类加锁,加到实例方法和代码块上是给对象实例加锁

public class Singleton {
    private volatile static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                instance = new Singleton();
            }
        }
        return instance;
    }
}

在单例模式中,instance采用volatile关键字修饰也非常关键,防止了在new Singleton(),这一步的初始化步骤和引入赋值步骤方法重排序

synchronized底层实现原理

实现一个程序的多线程同步互斥(一段代码,在任意时间点,只能有一个线程执行)

synchronized修饰的代码块在反编译为字节码时,代码块前后加入了monitor字样,前面的是monitoreneter,后面要离开的是monitorexit,两标志分别标志获取到锁和释放锁

当执行monitorenter指令时,当前线程将试图获取对象锁所对应的monitor的持有权,当对象锁的monitor的计数器为0时,那么线程就可以取得monitor、并将计数器设置为1,并且成功获取到对象锁。如果当前线程已经拥有对象锁的monitor的持有权,那它就可以重入这个monitor,重入的时候计数器也会加1。如果其他线程已经拥有对象锁的monitor所有权,那么线程经会被阻塞,直到获取到对象锁的线程执行结束,即monitorexit指令被执行,执行后对象锁就会被释放并且会将计数器设置为0

两个monitor标志的原因

如果只有一个获取对象锁的monitor标志,则在异常情况下退出程序后对象锁依旧无法正常释放,如此会导致死锁,所以设置后一个monitor标志,无论程序以何种方式退出,最终都会执行到monitorexit,在此处进行释放锁操作

synchronized可重入锁的原理

重入锁是为了使同一个线程金可重复的获取一个对象锁,底层基于一个计数器,每获取到一次就+1,释放一次就-1

自旋

在实际场景中,很多被synchronized加锁的对象,其执行使用过程都很快,此时如果将其他线程全部设为阻塞态,会涉及到用户态和内核态的切换十分耗时。所以引入自旋的操作,让没有抢占到锁的线程一直循环,不断尝试获取锁,如此以来提高了效率。

synchronized锁升级底层的原理

在锁对象的对象头里有一个threadid字段,在第一次访问的时候threadid为空,JVM让其持有偏向锁,并将threadid设置为其线程id,再次进入的时候会先判断threadid是否与其线程id一致,如果一致直接使用对象,如果不一致,则升级为轻量级锁;通过自旋一点次数来获取锁,如果执行一段次数后还没有获取到锁,此时就会把锁升级为重量级锁。

CAS

概念

CAS——CompareAndSwap——比较并交换
CAS含三个操作数——内存位置(V)、预期指(A)、拟写入的新值(B)

第一步:比较内存位置(V)与预期值(A)是否相等
第二步:如果相等,就把拟写入的新值(B)写入内存位置(A)
第三部:返回boolean类型,表示操作是否成功

当多个线程同时对某个资源进行CAS操作时,只有一个线程会操作成功返回true,其他线程自旋等待。乐观锁就是CAS的典型实现

CAS会产生什么问题?

1.ABA问题
ABA问题就是V中的这个值从A变成了B,又从B变会了A,这个变化过程从CAS的角度看来,线程在这段时间并没有被占用
解决方案:引入版本号(如携带一个时间戳)
2.总是自旋开销过大
当抢占同一个锁的线程过多时,CAS自旋的概率就会比较大,从而浪费了很多CPU资源,此时的效率就会低于synchronized
3.只能保证一个共享变量的原子性操作
对于只对一个共享变量操作时,可以使用CAS的方式来保证原子性,但对多个共享变量操作时,CAS就不适用了,此时需要对其加锁。

Lock锁

Lock接口

Lock接口中提供的方法比synchronized加锁的同步方法和同步代码块更具扩展性,它允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象
其优势:

  • 使锁更加公平
  • 使线程在等待锁的时候响应中断
  • 可以让线程无法获取锁的时候立即返回或者等待一段时间
  • 可以在不同的范围,以不同的顺序获取和释放锁
    总的来说,Lock就是synchronized的扩展升级版,Lock提供了无条件的、可轮询的、可定时的、可中断的、可多条件队列的锁操作。

Lock接口api

1、void lock();//获取锁
2、void lockInterruptibly;//获取锁的过程能够响应中断
3、boolean tryLock();//非阻塞式响应中断能立即返回,获取锁返回true反之为false
4、boolean tryLock(long time,TimeUnit unit);// 超时获取锁,在超时内或未中断的情况下能获取锁
5、Condition newCondition(); // 获取与lock绑定的等待通知组件,当前线程必须先获得了锁才能等待,等待会释放锁,再次获取到锁才能从等待中返回

通常使用显示使用lock的形式如下:

public class Test {
    public volatile static int COUNT = 0;
    public static void main(String[] args) {
        Lock lock = new ReentrantLock();
        Thread[] threads = new Thread[20];//创建一个线程数组同于存放接下来创建的线程
        for(int i = 0; i < threads.length; i++){
            Runnable r = new Runnable() {//使用匿名内部类创建线程
                @Override
                public void run() {
                    for(int i = 0; i < 1000;i++) {
                        lock.lock();
                        try {
                            COUNT++;
                        }finally {
                            lock.unlock();
                        }
                    }
                }
            };
            threads[i] = new Thread(r);
        }
        for(Thread t : threads){//依次启动这20个线程
            t.start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();//保证Thread数组中的所有线程都跑完
        }
        System.out.println(COUNT);
    }
}

Lock实现的子类

  • ReadWriteLock
  • ReentrantLock
  • ReentrantReadWriteLock
  • StampedLock
AQS概念

AQS——抽象的队列式同步器,是一个用来构造锁和同步器的框架,适用AQS简单且高效的构造出应用广泛地大量的同步器,比如ReentrantLock、Semaphore,其他诸如RenntrantReadWriteLock、SynchronousQueue、FutureTask等都是基于AQS的

AQS实现了对同步状态的管理,以及对阻塞线程进行排队,等待通知等一系列底层的实现

AQS原理分析

AQS的核心思想是:如果请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那就需要一套线程阻塞等待以及被唤醒时锁的分配机制,这个机制时AQS使用CLH队列(虚拟双向队列)锁实现的,即将暂时获取不到锁的线程加入到队列中。

AQS资源共享的方式

AQS底层定义了两种资源共享的方式:

  • Exclusive(独占):只有一个线程可以获取到资源,如ReentrantLock,又可以分为公平锁和非公平锁;
    公平锁:按照线程在队列中的排列顺序,先进入队列的先获取资源
    非公平锁:当线程要获取资源时,无视队列顺序,直接抢占锁
  • Share(共享):多个线程可以同时执行;如Semaphore、CountDownLatch
ReentrantLock(可重入锁)

ReentrantLock可重入锁,是实现了Lock接口的一个类,支持重入性,表示能够对共享重复加锁,即当前线程获取到该锁之后再次获取不会被阻塞

Java中synchronized关键字隐式的支持重入性,synchronized关键字是通过monitor的计数器来自增实现的。

实现原理:

  • 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功
  • 由于锁会被获取n次,那锁只有被释放n次之后,该锁猜能算被真正释放成功
ReadWriteLock(读写锁)

ReadWriteLock是一个读写锁接口,读写锁是用来提升并发程序性能的分离技术,ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,实现了读写的分离,读锁时资源是共享的,写锁时独占

读写锁具有三个重要特征:

  • 公平选择性:支持非公平和公平的锁获取方式,吞吐量还是非公平优于公平锁
  • 重入锁:读锁和写锁都支持线程重入
  • 锁降级:遵循获取写锁、获取读锁再释放写锁的顺序,写锁可以降级为读锁

ThreadLocal

ThreadLocal用于提供线程局部变量,在多线程环境中可以保证各个线程里的变量独立于其他线程的变量。可以理解为ThreadLocal为每个线程创建一个单独的共享变量副本互不影响

多个线程中使用ThreadLocal,是操作自己线程独立的变量,线程之间不相关

ThreadLocal是保证多线程环境下数据的独立性

ThreadLocal应用实例

public class Test {
    private static String commStr;
    private static ThreadLocal<String> threadStr = new ThreadLocal<String>();
    public static void main(String[] args) {
        commStr = "main";
        threadStr.set("main");
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                commStr = "thread";
                threadStr.set("thread");
                System.out.println("线程"+Thread.currentThread().getName()+":");
                System.out.println("commStr:"+commStr);
                System.out.println("threadStr:"+threadStr.get());
            }
        });
        thread.start();
        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("线程"+Thread.currentThread().getName()+":");
        System.out.println("commStr:"+commStr);
        System.out.println("threadStr:"+threadStr.get());
    }
}

执行结果
在这里插入图片描述

ThreadLocal的底层原理

本质上ThreadLocal使每个线程的ThreadHashMap都维持着自己的共享变量副本,从而做到各个线程独立

  • Thread类中有一个ThreadHashMap的数据结构,用来保存线程对象的变量
  • get()、put()、remove()方法都会获取到当前线程,然后通过当前线程获取到ThreadHashMap,如果ThreadHashMap为null,则会创建一个新的ThreadHashMap给当前线程
  • 在使用ThreadLocal类型变量操作时,都会通过当前线程获取到ThreadHashMap来操作。每个线程的ThreadHashMap都是属于线程自己的,ThreadHashMap中维持的值也是属于线程自己的,这就保证了ThreadLocal类型变量在每个线程中都是独立的,多线程同时操作不会互相影响

线程池

线程池可以类比JMM中常量池的概念,其中事先存放好了一定数量的线程,当程序需要在某个线程下执行时,线程池就会分配出一个线程用于执行对应的程序。
线程池的引入解决了每次执行程序都需要消耗很大性能创建销毁线程的弊端,减少了每次启动、销毁线程的损耗。

线程池的主要执行流程

在这里插入图片描述

  1. 线程池判断核心线程池里的线程是否都处于工作状态,如果不是创建一个工作线程来执行任务,如果是则进入下一流程
  2. 线程池判断工作队列是否已满,如果没有则将新提交的任务存储到该工作队列中,反之进入下一流程
  3. 线程池判断池中所有线程是否处于工作状态,如果没有创建一个新的线程来执行任务,反之则交由饱和策略来处理

Executors类创建的四种常见线程池

在工具类Executors中提供了一些静态工厂方法用来生成一些常用的线程池:

  1. newSingleThreadExecutor:创建一个单线程的线程池,这个线程池只有一个线程在工作,也就是相当于单线程串行的执行所有任务
  2. newFIxedThreadPool:创建固定大小的线程池,每次提交一个任务就会创建一个线程,直到线程达到线程池的最大值
  3. newCachedThreadPool:创建一个可缓存的线程池,如果线程池的大小超过了处理任务所需的线程,那么就会回收部分空闲线程(60秒内不执行任务的线程),当任务数量增加时,此线程又可以智能的增添新线程来处理任务,此线程池不会对线程的大小作出限制,线程池的大小依赖于操作系统能够创建最大线程的大小
  4. newScheduleThreadPool:创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求

ThreadPoolExecutor来创建线程池

ThreadPoolExecutor只有一种创建线程池的方式——通过自定义参数来创建线程池

ThreadPoolExecutor pool = new ThreadPoolExecutor(
                4,//corePoolSize:核心线程数
                10,//maximumPoolSize:最大线程数(核心线程+临时线程)
                60,//keepAliveTime:空闲时间数(临时线程可空闲的最长时间,超过该时间临时线程就会被销毁)
                TimeUnit.SECONDS,//unit:时间单位
        new ArrayBlockingQueue<>(1000),//workQueue:阻塞队列(存放线程的容器)
                new ThreadFactory(){//threadFactory:匿名内部类
                    @Override
                    public Thread newThread(Runnable r){
                        //线程的工厂类
                        return new Thread(r);
                    }
                },
                //handler:拒绝策略
                //1. new ThreadPoolExecutor.AbortPolicy()//抛异常的方式
                //2. new ThreadPoolExecutor.CallerRunsPolicy()//
                //3. new ThreadPoolExecutor.DiscardOldestPolicy()//把阻塞队列存放时间最久的任务丢弃
                //4.
                new ThreadPoolExecutor.DiscardPolicy());//不处理该任务,直接丢弃

核心参数:

  • corePoolSize:核心线程数,线程定义的最小可以同时运行的线程数量
  • maximumPoolSize:线程池中允许存在的工作线程的最大数量
  • workQueue:存放待执行任务的容器,如果新任务进入,此时所有线程都在工作状态,则将任务存放在容器中,等待有空闲线程来取出

线程池的使用示例

public class Test {
    public static int COUNT;
    public static void main(String[] args) {
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                10,
                30,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1000),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r);
                    }
                },
                new ThreadPoolExecutor.DiscardPolicy());


        Lock lock = new ReentrantLock();
        Runnable r = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    lock.lock();
                    try {
                        COUNT++;
                    } finally {
                        lock.unlock();
                    }
                }
            }
        };


        for(int i = 0 ;i < 20; i++) {
            pool.execute(r);
        }
        while (pool.getActiveCount() > 0){
            Thread.yield();
        }
        pool.shutdown();
        System.out.println(COUNT);
    }
}

执行结果
在这里插入图片描述

死锁

线程死锁是指两个或两个以上线程在执行过程中,互相持有对方的资源并且不主动释放造成的死循环,这些永远在互相等待的线程/进程称为死锁

死锁示例

public class DeadLock {
    private static Integer A = 0;
    private static Integer B = 10;

    public static void main(String[] args) {
        deadLock();
    }

    private static void deadLock() {
        Thread threadA = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程A:正在执行...");
                System.out.println("线程A:开始获取A对象锁...");
                synchronized (A){
                    System.out.println("线程A:获取A对象锁成功");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("线程A:线程A开始获取B对象...");
                    System.out.println("线程A:获取B对象锁成功");
                    synchronized (B){
                        Integer t = A;
                        A = B;
                        B = t;
                    }
                }
            }
        });

        Thread threadB = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程B:正在执行...");
                System.out.println("线程B:开始获取B对象锁...");
                synchronized (B){
                    System.out.println("线程B:获取B对象锁成功");
                    System.out.println("线程B:开始获取A对象锁...");
                    synchronized (A){
                        System.out.println("线程B:获取A对象锁成功");
                        System.out.println(A);
                        System.out.println(B);
                    }
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在这里插入图片描述

造成死锁的四个必要条件

  1. 互斥条件:线程对于所分配到的资源具有排他性,即一个资源只能被一个线程占用,直到被线程释放
  2. 请求与保持条件:一个线程因为请求被占用的资源而发生阻塞时,对已获得的资源保持不放
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只能由自己使用完毕后才会释放
  4. 循环等待条件:当发生死锁时,锁等待的线程必定会形成死循环

避免死锁的方法

只需要破坏上述的四个条件之一即可:

  • 破坏互斥条件:因为互斥条件是锁本身的特性,无法破坏
  • 破坏请求与保持条件:把要申请的资源封装成一个类,再对其对象加锁
  • 破坏不剥夺条件:可设置当占用部分资源发现申请其他资源申请不到时,主动释放已申请的资源,再重新申请
  • 破坏循环等待条件:按某个顺序申请资源,释放资源按照反序释放

具体方法:

  • 尽量使用tryLock(long timeout,TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,使超时就退出,防止死锁
  • 尽量使用java.util.concurrent并发类来代替自己手写的锁
  • 尽量降低锁的使用粒度,不要多个功能使用同一把锁

死锁与活锁的区别

死锁:指两个或两个以上线程在执行过程中,互相持有对方的资源并不主动释放而造成的死循环

活锁:指线程没有阻塞,只是由于某些条件没有满足,而导致线程一直重复获取锁的过程

区别:处于活锁的线程状态在不断的改变,但处于死锁的线程状态一直没有改变处于等待状态,活锁有可能自行解开,而死锁不能

以上便是对多线程安全的知识点小结,随着后续学习的深入还会同步的对内容进行补充和修改,如能帮助到各位博友将不胜荣幸,敬请斧正



这篇关于多线程环境下Java如何实现线程安全的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程