并发编程之线程基础与三大特性

目录

一、进程和线程

1.线程创建方式

 2.线程状态

3.结束线程

二、并发变成三大特性

可见性

有序性

原子性

synchronized

CAS乐观锁


一、进程和线程

        首先先明确两个基本概念,进程和线程分别是什么:

        进程:进程是操作系统分配资源的基本单位,一个运行的项目就是一个进程。

        线程:线程是调度执行的基本单位,一个进程一般都是由多个线程组成。

        并发编程的一个目的就是充分录用多核cpu提供的高性能,提高接口的响应时间。那么单核cpu是否有必要进行多线程开发呢?答案是肯定有必要的。比如现在一个方法里面有两个线程,一个线程是依赖于用户输入,另一个线程对这个步骤没有任何依赖,那么在等待用户输入的时候,可以先让另一个线程执行。

        需要注意的是,线程数量并不是越高越好,需要根据cpu的相关情况以及项目的相关情况进行确定,计算公式如下,但是也只是一个参考数量,还是要进行项目监控以及经验来确定线程数量:

        线程数量 = CPU核数*CPU利用率*(1+等待时间与计算时间的比例)

1.线程创建方式

线程创建方式如果细分,有以下五种:

/**
     * 第一种,继承Thread类,重写run方法
     */
    static class MyThread extends Thread{
        @Override
        public void run(){
            System.out.println("create thread method one");
        }
    }
    /**
     * 第二种,实现Runnable接口,重写run方法,没有返回值
     */
    static class MyRun implements Runnable{
        @Override
        public void run() {
            System.out.println("create thread method TWO");
        }
    }
    /**
     * 第三种,实现Callable接口,重写call方法,有返回值
     */
    static class MyCall implements Callable<String>{
        @Override
        public String call() throws Exception {
            System.out.println("create thread method THREE");
            return "success";
        }
    }

    public static void main(String[] args) {
        // 调用线程start()方法,才是将线程启动,
        // 如果是调用run方法,那么相当于是调用一个类的普通方法
        new MyThread().start();
        new Thread(new MyRun()).start();
        // 第四种,使用lambda表达式,这种方式相当于是方法一的简写方式
        new Thread(() ->{
            System.out.println("create thread method FOUR");
        }).start();
        new Thread(new FutureTask<String>(new MyCall())).start();
        // 第五种,使用线程池创建
        ExecutorService service = Executors.newCachedThreadPool();
        service.execute(() -> {
            System.out.println("create thread method FIVE");
        });
        service.shutdown();
    }

需要注意的是,当创建线程以后,要启动线程是调用当前线程start()方法,而不是调用run()方法,调用run()方法只是想防御普通方法的调用。

 2.线程状态

        线程状态主要分为以下六种:

        NEW:线程刚被创建,但是还没有调用start()方法启动;

        RUNNABLE:可运行状态,它又可以细分成两个状态:READY和RUNNING两个状态,他们两个的区别是有没有被CPU分配时间片去运行,如果分配就是RUNNING状态,否则就是READY状态;

        WAITING:等待被唤醒状态,例如调用wait(),join()等方法时,进入此状态;

        TIMED WAITING:等待一段时间后自动唤醒,例如调用sleep(2)方法时,进入此状态;

        BLOCKED:阻塞状态,当多个线程竞争一个锁,在等待锁释放的时候,就会处于该状态;

        TERMINATED:线程结束销毁。

        线程状态的变化如下图:

        

3.结束线程

         除了线程运行完毕,线程正常结束以外,还能采用下面的四种方式进行结束:

         1.调用线程的stop()方法,但是这种方法不建议使用,它会对事务产生影响,造成数据不一致的情况,例如在一个事务中,在调用stop()方法的时候整个事务还没有执行完毕,那么在stop()之前进行的数据修改不会进行回滚。

        2.suspend()暂停/resume()恢复,这一对指令也不建议进行使用,因为suspend()方法不会释放锁,会让当前线程一直持有,如果一直没有调用resume()方法,锁资源将一直不会释放。

        3.使用volatile关键字修饰一个状态码,当不是指定状态码的时候,线程去执行相关的业务程序,但是当线程达到指定的状态码的时候,线程正常结束。但是可能存在的问题是,如果当前线程执行了wait()方法,那么它会处于等待状态,此时无法进入下次循环也就无法跳出循环,结束线程。

        4.使用interrupt,打断标志位的方式来结束线程。这种方式是线程自带的一种方式,它比volatile关键字修饰状态码的方式会更好一些。下面对interrupt进行一些介绍。

        interrupt(): 打断线程,需要注意这里的"打断",它只是给当前线程添加上一个打断的标志位,并不是真的打断线程;

        isInterrupt():获取当前线程的打断标志位

        static interrupted:查询当前线程的打断标志位,并进行重置。

        需要注意的一点是,当前线程正在执行wait(),sleep()方法,这时候当前线程执行interrupt()方法,会抛出InterruptException。上面提到的interrupt()的好处就是,通过try catch捕捉这个异常,在catch中结束线程,这样就不会让线程一直等待。

二、并发变成三大特性

可见性

        所谓可见性指的是,针对多个线程共享的一个变量,当其中一个线程对该变量进行了改变,其它线程都能读取到改变以后的值。

        在java中可以使用关键字volatile关键字来实现变量的可见性,该关键字可以保证工作内存和主内存中的数据信息保持一致。但是需要注意一点的是,当使用volatile关键字修饰引用类型时,它只能保证整个引用类型的可见性,也就是引用地址的可见性,但是针对引用类型中字段的属性是无法保证其可见性的,例如volatile T t = new T(),T中有int a;这个属性,如果t的引用地址发生变更,那么其它线程是可见的,但是如果是a这个字段的值发生变更,那么其它线程是不一定可见的。

        从cpu层面分析可见性,指的是不同cpu之间缓存和内存数据的可见性。CPU具有三级缓存,cpu中的每个核具有自己的一级缓存和二级缓存,每个cpu有自己的三级缓存,在cpu里面实现可见性是通过缓存一致性协议实现(常见的是mesi协议)。需要注意volatile的可见性和缓存一致性协议是没有关系的。结合下图进行进一步了解

        

 (缓存行:缓存行指的是从内存中读取到缓存中的基本单位,大小为64byte。注解contended可以保证一个不满64字节长度的字段按照一个缓存行读取,但是使用这个注解的时候需要在运行时添加-XX:-RestrictContended。这个注解在1.9以后不再起作用

有序性

        程序中的所有语序其实并不是我们认为的就是一个操作就能完成的,它在底层字节码层次是由多个指令组成的,cpu为了提高执行效率,在不影响单线程执行最终一致性的前提下会对指令进行重排序。下面举一个典型的例子说明:

        Object object = new Object();这个语句在java程序可以看成就是一个操作创建一个object对象,那么在字节码层次是怎么实现的呢,如下图(使用jcalssLib插件):

它是由以上5个指令组成,具体指令代表含义可以查看官方文档。它大致分为三个步骤:1)申请内存空间,并赋默认值;2)执行构造方法赋初始值;3)将内存指向object。cpu在执行的时候不一定按照1->2->3的顺讯执行,执行顺序可能变成1->3->2这种执行顺序,在单线程中是没有任何问题的,因为最终结果都是一个赋完初始值的对象。但是如果是在多线程中就可能出现问题,以下面这段DCL代码为例分析:

public class Single {
    private volatile static Single SINGLE;
    private Single(){ }
    public static Single getInstance(){
        if (SINGLE == null){
            synchronized (Single.class){
                if (SINGLE == null){
                    SINGLE = new Single();
                }
            }
        }
        return SINGLE;
    }
}

 上述代码中的volatile这个关键字是否是必须的呢?答案是肯定的,如果没有这个关键字,就有可能出现乱序执行导致最终结果出问题,现在有两个线程,一个线程执行到了SINGLE = new Single()方法,另外一个线程执行到了第一个空判断,如果新建对象发生乱序,按照1->3->2的顺序执行到了步骤3,第二个线程返现对象不为空,则会将只服了默认指的SINGLE对象拿去使用,就可能会出现问题,因此DCL中的volatile关键字是必须的。

        那么有序性是如何保证的呢(详见前文):

        硬件层次:cpu使用cpu级别的内存屏障实现:lfence,sfence,mfence;

        jvm层次:虚拟机使用虚拟机级别的内存屏障实现:LoadLoad,StoreStore,LoadStore,StoreLoad。需要注意的是虚拟机层次的内存屏障是通过调用硬件层次的相关指令完成,例如lock指令。

原子性

        在了解原子性之前先了解几个概念: race condition:竞争条件,多个线程访问共享数据时产生竞争;monitor:锁; critical section:临界区,线程持有锁的时候执行的代码;锁粒度:临界区执行时间长,语句多,代表锁粒度比较粗;反之,锁粒度比较细。

        所谓原子性,指的是这段代码的执行不能被打断,在当前线程还未执行完毕之前,其它线程无法打断该线程代码的执行。可以通过对代码进行加锁来保证原子性。

        锁分为以下两种:

        1.悲观锁:主要是synchronized,无论是否可能发生锁的情况,直接进行加锁。

        2.乐观锁:以CAS自旋锁为代表,他会先去默认当前代码不需要进行锁定,但是在最终提交的时候,会进行判断是否有线程竞争,如果没有,则执行结束;否则,需要重新执行。

        针对上述两种锁的概念,他们有自己的适合使用场景:因为cas自旋锁,线程需要自旋等待,会浪费cpu资源,因此临界区执行时间长,争抢线程多时,使用synchronized悲观锁;如果执行临界区执行时间短,争抢线程少时,使用CAS乐观锁。

        在上面提到了synchronized和CAS,接下来针对这两个锁进行介绍。

synchronized

        synchronized可以保证可见性以及原子性,但是它不能保证有序性。在优化之前是一个重量级锁,每次加锁都是依赖于操作系统完成;优化之后,它的效率更高,不再是一加锁就是向操作申请锁,它会进行一系列的锁升级过程。

        在介绍锁升级过程之前,需要知道锁的相关信息是记录在mark word中,因此也要求我们当使用一个引用对象作为锁的时候,不要修改引用对象,避免对象的mark word发生改变,导致锁失效。

        synchronized的正常锁升级过程: 无锁->偏向锁->轻量级锁(自旋锁)->重量级锁。其中偏向锁和轻量级锁位于用户空间,而重量级锁向内核空间申请。

        当一个线程执行synchronized锁定的临界区时,将无锁对象升级为偏向锁,会在锁的mark word上记录当前线程指针,不会进行其它操作,mark word上记录的hashcode值会放入线程栈中记录;当该线程未执行结束,有少于10个并且低于cpu核数一半以下的线程争抢锁时,偏向锁升级为轻量级锁,此时会首先进行偏向锁的锁消除,竞争线程会在自己的线程栈中生成LR(lock record),这些线程谁先将自己的LR记录到锁的mark word上,谁就获得了偏向锁,而其它线程则开始自旋等待;当竞争线程超过10个以后,轻量级锁升级为重量级锁,此时会向操作系统进行锁申请,争夺锁的线程会被放入操作系统的相关队列中,获取到锁的线程正常运行,而其它线程计入等待队列中等待锁释放。以上就是锁升级的整个过程。当然该过程不是必须的,有一些特殊情况,会造成锁升级的过程发生变更。

        当偏向锁还未启动的时候,锁升级流程为:无锁对象->轻量级锁->重量级锁。可以通过参数-XX:BiasedLockingStartupDelay=4000设置偏向锁延时多长时间启动,默认4s。

        synchronized属于可重入锁,所谓可重入锁指的是,线程1获取到锁a,在未释放锁之前还需要再次获取锁a,如果允许就是可重入锁;如果不允许就是不可重入锁。syncronized既然为可重入锁,那么就需要记录重入次数,因为需要根据重入次数进行对应次数的解锁。对于偏向锁和轻量级锁是通过往线程栈中放入LR来记录重入次数。重量级锁会将重入次数信息记录到操作系统中。

CAS乐观锁

        cas(comapre and swap)自旋锁是在用户空间执行,不会涉及到内核空间。他的一个大致工作流程是先进性临界区代码执行,当执行完毕以后那当前值与期望值进行比较,如果通过,将当前值更新为期望值,执行结束,如果不一致,则循环执行上述流程。如下图所示:

在使用CAS的时候可能会出现ABA问题,即当前线程获取到的C是其它线程处理完毕以后更新的C,为了解决这种问题,可以通过添加版本号的方式解决。如果是基本数据类型的话,ABA问题可能不会影响业务的正常运行,但是需要注意如果是引用对象类型的话,在比较的时候不能只进行地址的比较,要重新equal方法,不然会出现问题。

        atomic原子类,里面的相关操作是原子性的,它的原子性就是使用CAS乐观锁实现的。它的CAS是依赖于汇编指令cmpxchg实现。如果是多核cpu需要加上lock指令进行锁定,因为cmpxchg指令本身不是原子性,所以需要进行锁定。

猜你喜欢

转载自blog.csdn.net/weixin_38612401/article/details/123916565