美文网首页
多线程与线程安全

多线程与线程安全

作者: 码道功臣 | 来源:发表于2019-05-06 19:01 被阅读0次

    多线程核心问题

    多线程要解决的核心问题包括三个,分别是原子性问题,可见性问题,有序性问题

    原子性

    即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
    经典的例子就是银行转账案例。

    代码实例1:

    i = i + 1;
    

    上述代码最终在CPU中执行的过程:
    每个线程在执行上述代码过程中,为了提高CUP性能,会将i值从主存中COPY到CUP的高速缓存中(每个线程运行时有自己的高速缓冲区),当计算完成后,先将计算结果放到高速缓存中,然后再刷新到主存中。
    多线程情况下就会出现,由于i值的更新没有同步到其他线程导致计算结果出错的问题。

    代码实例2:

    x = 10;    //语句1,原子操作
    y = x;     //语句2,非原子操作。读取x并写入工作内存 > 将x赋值给y > 将y值更新到工作内存 > 将y值更新到主存
    x++;       //语句3,非原子操作。读取x并写入工作内存 > x累加1 > 将x更新到工作内存 > 将x更新到主内存
    x = x + 1; //语句4,非原子操作。读取x并写入工作内存 > 将x加1 > 将x更新到工作内存 > 将x更新到主内存
    

    从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

    可见性

    是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改这个修改。

    对于可见性,Java提供了volatile关键字来保证可见性。

    • 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
    • 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
    • 另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

    有序性

    即程序执行的顺序按照代码的先后顺序执行。

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

    int a = 10;   //语句1
    int r = 2;    //语句2
    a = a + 3;    //语句3
    r = a * a;      //语句4
    

    上面代码经过指令重排后的执行顺序可能是: 语句2 -- 语句1 -- 语句3 -- 语句4 。
    在多线程场景下,指令重排也会对执行结果产生影响。

    线程的生命周期

    图片.png

    Java线程具有五中基本状态:

    • 新建状态(New):当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
    • 就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行;
    • 运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就 绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
    • 阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同。
      阻塞状态又可以分为三种:
      1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
      2.同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
      3.其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
    • 死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

    线程的创建

    两种手段,一种是继续Thread类,另外一种是实现Runable接口.(其实准确来讲,应该有三种,还有一种是实现Callable接口,并与Future、线程池结合使用)
    如果一个类继承Thread,则不适合资源共享。但是如果实现了Runable接口的话,则很容易的实现资源共享。

    实现Runnable接口比继承Thread类所具有的优势:

    • 适合多个相同的程序代码的线程去处理同一个资源
    • 可以避免java中的单继承的限制
    • 增加程序的健壮性,代码可以被多个线程共享,代码和数据独立
    • 线程池只能放入实现Runable或callable类线程,不能直接放入继承Thread的类

    多线程控制类及关键字

    synchronized

    对象同步锁:synchronized是对对象加锁,可作用于对象、方法(相当于对this对象加锁)、静态方法(相当于对Class实例对象加锁,锁住的该类的所有对象)以保证并发环境的线程安全。同一时刻只有一个线程可以获得锁。

    其底层实现是通过使用对象监视器Monitor,每个对象都有一个监视器,当线程试图获取Synchronized锁定的对象时,就会去请求对象监视器(Monitor.Enter()方法),如果监视器空闲,则请求成功,会获取执行锁定代码的权利;如果监视器已被其他线程持有,线程进入同步队列等待。

    Lock

    与synchronized功能类似。

    Lock与synchronized的区别:
    1、Lock可以通过tryLock()方法非阻塞地获取锁。如果获取了锁即立刻返回true,否则立刻返回false。这个方法还有加上定时等待的重载方法tryLock(long time, TimeUnit unit)方法,在定时期间内,如果获取了锁立刻返回true,否则在定时结束后返回false。在定时等待期间可以被中断,抛出InterruptException异常。而synchronized在获得锁的过程中是不可被中断的。

    2、Lock可以通过lockInterrupt()方法可中断的获取锁,与lock()方法不同的是等待时可以响应中断,抛出InterruptException异常。

    3、synchronized是隐式的加锁解锁,而Lock必须显示的加锁解锁,而且解锁应放到finnally中,保证一定会被解锁,否则,有可能会产生死锁的问题。而synchronized在出现异常时也会自动解锁,但是对于锁的粒度控制比较粗,同时对于实现一些锁的状态的转移比较困难。但也因为这样,Lock更加灵活。

    4、synchronized是JVM层面上的设计,对对象加锁,基于对象监视器。Lock是代码实现的。

    可重入锁

    ReentrantLock与synchronized都是可重入锁。可重入意味着,获得锁的线程可递归的再次获取锁。当所有锁释放后,其他线程才可以获取锁。

    ReentrantLock具有公平和非公平两种模式,也各有优缺点:
    公平锁是严格的以FIFO的方式进行锁的竞争,但是非公平锁是无序的锁竞争,刚释放锁的线程很大程度上能比较快的获取到锁,队列中的线程只能等待,所以非公平锁可能会有“饥饿”的问题。但是重复的锁获取能减小线程之间的切换,而公平锁则是严格的线程切换,这样对操作系统的影响是比较大的,所以非公平锁的吞吐量是大于公平锁的,这也是为什么JDK将非公平锁作为默认的实现。

    公平锁与非公平锁

    “公平性”是指是否等待最久的线程就会获得资源。如果获得锁的顺序是顺序的,那么就是公平的。不公平锁一般效率高于公平锁。ReentrantLock可以通过构造函数参数控制锁是否公平。

    ReentrantReadWriteLock(读写锁)

    是一种非排它锁, 一般的锁都是排他锁,就是同一时刻只有一个线程可以访问,比如synchronized和Lock。读写锁就多个线程可以同时获取读锁读资源,当有写操作的时候,获取写锁,写操作时,其他读写操作都将被阻塞,直到写锁释放。读写锁适合写操作较多的场景,效率较高。

    乐观锁与悲观锁

    在Java中的实际应用类并不多,大多用在数据库锁上。

    死锁

    是当两个线程互相等待获取对方的对象监视器时就会发生死锁。一旦出现死锁,整个程序既不会出现异常也不会有提示,但所有线程都处于阻塞状态。死锁一般出现于多个同步监视器的情况。

    BlockingQueue

    阻塞队列。该类是java.util.concurrent包下的重要类,通过对Queue的学习可以得知,这个queue是单向队列,可以在队列头添加元素和在队尾删除或取出元素。类似于一个管道,特别适用于先进先出策略的一些应用场景。
    除了传统的queue功能(表格左边的两列)之外,还提供了阻塞接口put和take,带超时功能的阻塞接口offer和poll。put会在队列满的时候阻塞,直到有空间时被唤醒;take在队 列空的时候阻塞,直到有东西拿的时候才被唤醒。用于生产者-消费者模型尤其好用,堪称神器。
    常见的阻塞队列有:ArrayListBlockingQueue、LinkedListBlockingQueue、DelayQueue、SynchronousQueue

    ConcurrentHashMap

    ConcurrentHashMap从JDK1.5开始随java.util.concurrent包一起引入JDK中,主要为了解决HashMap线程不安全和Hashtable效率不高的问题。众所周知,HashMap在多线程编程中是线程不安全的,而Hashtable由于使用了synchronized修饰方法而导致执行效率不高;因此,在concurrent包中,实现了ConcurrentHashMap以使在多线程编程中可以使用一个高性能的线程安全HashMap方案。
    JDK1.7之前的ConcurrentHashMap使用分段锁机制实现,JDK1.8则使用数组+链表+红黑树数据结构和CAS原子操作实现ConcurrentHashMap。

    ThreadPoolExecutor

    ExecutorService e = Executors.newCachedThreadPool();
    ExecutorService e = Executors.newSingleThreadExecutor();
    ExecutorService e = Executors.newFixedThreadPool(3);
    // 第一种是可变大小线程池,按照任务数来分配线程,
    // 第二种是单线程池,相当于FixedThreadPool(1)
    // 第三种是固定大小线程池。
    // 然后运行
    e.execute(new MyRunnableImpl());
    

    该类内部是通过ThreadPoolExecutor实现的,掌握该类有助于理解线程池的管理,本质上,他们都是ThreadPoolExecutor类的各种实现版本。

    volatile

    被volatile修饰的变量:

    • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
    • 添加“内存栅栏”,禁止进行指令重排序。

    volatile关键字描述后的代码会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏)。

    内存屏障的功能:

    • 保证指令重排序的正确性(volatile 变量代码禁止指令重排序);
    • 强制将缓存的修改立即同步到主存;
    • 如果是写操作,它会导致其他CPU中对应的缓存行失效。

    volatile相当于轻量级的synchronized,synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。

    相关文章

      网友评论

          本文标题:多线程与线程安全

          本文链接:https://www.haomeiwen.com/subject/pjsdoqtx.html