Java并发与多线程与锁优化

2019-12-24  本文已影响0人  Lemonrel

前言
  目前CPU的运算速度已经达到了百亿次每秒,所以为了提高生产率和高效地完成任务,基本上都采用多线程和并发的运作方式。
  并发(Concurrency):是指在某个时间段内,多任务交替处理的能力。CPU把可执行时间均匀地分成若干份,每个进程执行一段时间后,记录当前的工作状态,
释放相关的执行资源并进入等待状态,让其他线程抢占CPU资源。
  并行(Parallelism):是指同时处理多任务的能力
在并发环境下,由于程序的封闭性被打破,出现了一下特点:
  1、并发程序之间有相互制约的关系。直接制约体现在一个程序需要另一个程序的计算结果;间接体现为多个程序竞争共享资源,如处理器、缓冲区等。
  2、并发程序的执行过程是断断续续的。程序需要记忆现场指令及执行点
  3、当并发数设置合理并且CPU拥有足够的处理能力时,并发会提高程序的运行效率。
线程安全
  线程是CPU调度和分派的基本单位,为了更充分地利用CPU资源,一般都会使用多线程进行处理。多线程的作用是提高任务的平均执行速度,但是会导致程序可理解性变差,编程难度加大。
  线程安全的定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
  线程可以拥有自己的操作栈、程序计数器、局部变量表等资源,它与同进程内的其他线程共享该进程的所有资源。线程在生命周期内存在多种状态。有NEW(新建状态)、
RUNNABLE(就绪状态)、RUNNING(运行状态)、BLOCKED(阻塞)状态、DEAD(终止状态)五种状态。
  1、NEW,即新建状态,是线程被创建且未启动的状态。创建线程的方式有三种,第一种是继承自Thread类,第二种是实现Runnable接口。第三种是实现Callable接口。
  推荐使用实现Runnable接口的方式,因为继承Thread类往往不符合里氏替换原则(任何父类出现的地方都可以用子类替换,子类不要重写重载父类的方法)。
  Callable与Runnable有两点不同:
    1):Callable可以通过call()获得返回值。
    2):call()方法可以抛出异常。而Runnable只有通过setDefaultUncaughtExceptionHandler()的方式才能在主线程中捕捉到子线程异常。
  2、RUNNABLE,即就绪状态,是调用start()方法后运行之前的状态。需要注意的是线程的start()不能被多次调用,否则会抛出IllegalStateException异常
  3、RUNNING,即运行状态,是run()正在执行时线程的状态。线程可能会由于某些因素而退出RUNNING,如时间、异常、锁、调度等
  4、BLOCKED,即阻塞状态,进入此状态,有以下几种情况
    同步阻塞:锁被其他线程占用
    异步阻塞:调用Thread的某些方法,主动让出CPU执行权,比如sleep()、join()等
    等待阻塞:执行了await()
  5、DEAD,即终止状态,是run()方法执行结束,或因异常退出后的状态,此状态不可逆转。
线程安全的核心理念就是“要么只读,要么加锁”
线程安全问题只有在多线程环境下才出现,单线程串行执行不存在此问题。保证高并发场景下的线程安全,可以从以下维度考量:
  1、数据单线程内可见:单线程总是安全的。通过限制数据只在单线程内可见,可以避免数据被其他线程篡改。最典型的就是线程局部变量,它存储在独立的
    虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。ThreadLocal就是采用这种方式来实现线程安全的。
  2、只读对象:只读对象总是线程安全的。它的特性是允许复制、拒绝写入。最典型的只读对象有String,Integer等。一个对象想要拒绝任何写入,必须满足以下条件:
    1):使用final关键字修饰类。避免被继承,如String,调用其的方法不会影响其原来的值,只会返回一个新构造的字符串对象
    2):使用private final 关键字避免属性被中途修改
    3):没有任何更新方法
    4):返回值不能可变对象为引用
  3、线程安全类:某些线程安全类内部有非常明确的线程安全机制。比如StringBuffer就是一个线程安全类,其内部采用sychronized关键字来修饰相关方法
  4、同步与锁机制:如果想要对某个对象进行并发更新操作,但又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。
  合理利用好JDK提供的并发包(java.util.concurrent),并发包主要分为以下几个类族:
    1):线程同步类,这些类使得线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用Object类的wait和notify进行同步的方式,主要代表为
      CountDownLatch、Semaphore、CycleBarrier等
    2):并发集合类,如ConcurrentHashMap,它不断优化,从刚开始的锁分段到后来的CAS,不断地提升并发性能。其他还有BlockingQueue、CopyOnWriteArrayList等
    3):线程管理类,如使用Executors静态工厂或者使用ThreadPoolExecutor来创建线程池等,另外,通过ScheduledExecutorService来执行定时任务
    4):锁相关类。锁以Lock为核心,最有名的是ReentrantLock。
线程安全的实现方法
  1、互斥同步
  同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥方式。
  synchronized:
  在Java语言中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和moniterexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者Class对象来作为锁对象。
  在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定(monitor为0),或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
  synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。Java中的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态之中,因此状态转换需要耗费很多的处理器时间。
对于简单的同步代码块,状态转换的操作有可能比用户代码执行的时间还要长。所以synchronized是Java语言中的一个重量级的操作。同时虚拟机本身也做了一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待的过程,避免频繁地切入到核心态中。
  Lock:
  相比synchronized,ReentrantLock增加了一些高级功能,只要有以下3项:等待可中断、可实现公平锁、以及锁可以绑定多个条件。
  等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他的事情,可中断特性对处理执行时间非常长的同步块很有帮助。
  公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
    synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
  锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无需这么做,只需要多次调用newCondition()方法即可。
  2、非阻塞同步
  互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认
为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被
阻塞的线程需要唤醒等操作。
  随着硬件指令集的发展,我们可以选择:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据
有争用,产生了冲突,那就再采用其他的补偿措施(常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步
操作称为非阻塞同步。
  为什么使用乐观并发策略需要”硬件指令集的发展“才能进行呢?因为我们需要操作和冲突检测这两个步骤具备原子性,靠什么来保证呢?如果这里使用互斥同步来保证就
失去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
    1)、测试并设置(Test-and-Set)
    2)、获取并增加(Fetch-and-Increment)
    3)、交换(Swap)
    4)、比较并交换(Compare-and-Swap,CAS)
    5)、加载链接/条件存储(Load_Linked/Store-Conditional,LL/SC)
     其中后面的两条是现代处理器新增的。
  CAS指令需要3个操作数,分别是内存位置(在Java中可以理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,且上面的处理过程是一个原子操作。
  不过CAS有个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍为A值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了B,后来又被改为A,那CAS操作就会误认为它从来没有改变过。这个漏洞称为CAS操作的ABA问题。java.unit.concurrent包为了解决这个问题,提供了一个带有标记的原子引用类”AtomicStampReference“,它可以通过控制变量值的版本来保证CAS的正确性。不过这个类目前来说比较鸡肋,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
  3、无同步方案
  要保证线程安全,并不是一定要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的,比如:
  可重入代码(Reentrant Code):这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。...
  线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围控制在同一个线程之内,这样,无需同步也能保证线程之间不出现数据争用的问题。如ThreadLocal类可以实现线程本地存储的功能。每个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V键值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。
什么是锁?
    单机单线程时代,没有锁的概念。自动出现了资源竞争,人们才意识到需要对部分执行现场进行加锁,表明自己短暂拥有。计算机中的锁也从最开始的悲观锁,发展到后来的乐观锁、偏向锁、分段锁等。锁主要提供了两种特性:互斥性和不可见性。
  1、用并发包中的锁类
    Lock是顶层接口,它的实现逻辑并未用到synchronized,而是利用了volatile的可见性。ReentrantLock对了Lock接口的实现主要依赖了Sync,而Sync继承了
    AbstractQueuedSynchronizer(AQS),在AQS中,定义了一个volatile int state 变量作为共享资源。如果线程获取此共享资源失败,则进入同步FIFO队列中等待;
    如果成功获取资源就执行临界区代码。执行完释放资源时,会通知同步队列中的等待线程来获取资源后出对并执行。
    ReentrantLock的lock()方法默认执行的是NonfairSync中的lock()实现,利用Unsafe类的CAS;期望state值为0时将其值设为1,返回是否成功
    因此ReentrantLock的lock()方法只有在state为0时才能获得锁,并将state设为1。这样其他线程就无法获取锁,只能等待。
    由于ReentrantLock是可重入锁,即在获得锁的情况下,可以再次获得锁。并且线程可以进入任何一个它已经拥有的锁所同步着的代码块。若在没有释放锁的情况下,再次获得锁,则state加1,在释放资源时,state减1,因此Lock获取多少次锁就要释放多少次锁,直到state为0。
  2、利用同步代码块
    同步代码块一般使用Java的sychronized关键字来实现,有两种方式对方法进行加锁操作:
      1):第一,在方法签名处加synchronized关键字
      2):第二,使用synchronized(对象或类)进行同步
    这里的原则是锁的范围尽可能小,锁的时间尽可能短,即能锁对象,就不要锁类,能锁代码块,就不要锁方法。
  synchronized锁特性由JVM负责实现。在JDK的不断优化迭代中,synchronized锁的性能得到极大提升,特别是偏向锁的实现,使得synchronized已经不是昔日那个低性能且笨重的锁了。
  JVM底层是通过监视锁来实现synchronized同步的。监视锁即monitor,是每个对象与生俱来的一个隐藏字段。使用synchronized时,JVM会根据synchronized的当前使用环境,找到对应的monitor,再根据monitor的状态进行加、解锁的判断(使用monitorenter和monitorexit指令实现)。例如:线程在进入同步方法或者代码块时,会获取该方法或代码块所属对象的monitor(在Java对象头中),进行加锁判断。如果成功加锁就成为该moniter的唯一持有者。monitor在被释放前,不能被其他线程获取。
  从字节码看synchronized锁的具体实现:
  同步方法的方法元信息中会使用ACC_SYNCHRONIZED标识该方法是一个同步方法。同步代码块中会使用monitorenter及monitorexit两个字节码指令获取和释放monitor。
如果使用monitorenter进入时monitor为0,表示该线程可以持有monitor后续代码,并将monitor加1;如果当前线程已经持有了monitor,那么monitor继续加1(可重入);
如果monitor非0,其他线程就会进入阻塞状态(和Lock的state类似)。
  JVM对synchronized的优化主要在于对monitor的加锁、解锁上。JDK6后不断优化使得synchronized提供三种锁的实现,包括偏向锁、轻量级锁、重量级锁,还提供自动的升级和降级机制。JVM就是利用CAS在对象头上设置线程ID,表示这个对象偏向于当前线程,这就是偏向锁。
  偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销。在锁的对象头中有一个ThreadId字段,当第一个线程访问锁时,如果该锁没有被其他线程访问过,即ThreadId字段为空,那么JVM让其持有偏向锁,并将ThreadId字段设置为该线程的ID。当下一次获取锁时,会判断当前线程的ID是否与锁对象的ThreadId一致,如果一致,那么该线程不会再重复获取锁,从而提高了程序的运行效率。如果出现锁的竞争情况,那么偏向锁会被撤销并升级为轻量级锁。如果资源的竞争非常激烈,会升级为重量级锁。
偏向锁可以降低竞争开销,它不是互斥锁,不存在线程竞争情况,省去了再次判断的步骤,提升了性能。
锁优化
自旋锁和自适应锁:
  互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要从用户态转到核心态中去完成。这些操作给操作系统的并发性能带来了很大的压力。同时,在很多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器上有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程”稍等一下“,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,为了让线程等待,我们只需要让线程执行一个忙循环,即自旋,这项技术就是所谓的自旋锁。
  自旋锁在1.6之后默认开启,自旋等待不能代替阻塞,虽然避免了线程切换的开销(挂起唤醒,用户态转核心态),但是还是会占用处理器的时间,因此如果锁被占用的时间很短,那么自旋等待的效果就会非常好,如果锁占用的时间很长,那么自旋的线程只会白白消耗处理器资源,带来性能浪费。因此自旋等待的时间要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,那就应当用传统的方式去挂起线程了。自旋的次数默认是10次。
  1.6引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
锁消除:
  锁消除是指在虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁就无需进行。
锁粗化:
  原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小(减少锁时间),如果存在锁竞争,那等待锁的线程也能尽快拿到锁。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能消耗。如StringBuffer类的append()方法就是这种情况,每个append()方法都对同一个对象加锁,且append()可能连续出现多次。

    @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把锁同步的范围扩展(粗化)到整个操作序列的外部,如多个append()的话就会扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。
轻量级锁:
  1.6引入的新型锁机制,轻量级是相对使用操作系统互斥量来实现的传统锁而言的,传统的锁机制就称为重量级锁。轻量级锁并不能替代重量级锁,它的本意是在没有多线
程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
  要理解轻量级锁以及偏向锁的原理和运作过程,必须了解JVM的对象(对象头部分)的内部布局。HotSpot JVM的对象头(Object Header)分为两部分信息,第一部分用来存储对象自身的运行时数据,如哈希码(hashCode)、GC分代年龄、锁标志位等。官方称为Mark Word,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型的指针,如果是数组的话,还会有一个额外的部分用于存储数组的长度。
  Mark Word对象头在不同状态下的标识位存储内容如下:



轻量级锁的执行过程:在代码进入同步块的时候,如果此同步对象没有被锁定(锁标识位为01的时候),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝,加了一个前缀Displaced,即Displaced Mark Word。然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标识位修改为00,即表示此对象处于轻量级锁定状态。如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为10,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待的线程也要进入阻塞状态。
  可以看到轻量级锁的加锁过程是通过CAS来实现的,同样,解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
  轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,如果存在锁竞争,那么除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
偏向锁:
  1.6引入的一项锁优化,目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争情况下把整个同步都消除掉,并且连CAS操作都不做了。
  偏向锁的“偏”,就是偏心的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
  如果虚拟机开启了偏向锁,1.6默认开启,那么当锁对象第一次被线程获取的时候,虚拟机会把对象头中的标志为设为01,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。当有另外一个线程去尝试获取这个锁时,偏向模式宣告结束。根据锁对象目前是否出于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就按轻量级锁的过程来执行。
  偏向锁可以提高带有同步但无竞争的程序性能。但是它并不一定总是对程序有利,如果程序中大多数的锁总是被多个不同的线程访问,那么偏向模式就是多余的。
线程同步:
  即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存进行操作,一直等待直到该线程完成操作,其他线程才能对该内存进行操作。
  在多个线程对同一变量进行写操作时,如果操作没有原子性,就可能产生脏数据。所谓原子性,是指不可分割的一系列操作指令,在执行完毕前不能被任何其他操作中断,那么全部执行,要么全部不执行。如果每个线程对共享变量的修改都是原子操作,就不存在线程同步问题。
  i++操作就不具备原子性,它需要分成三部ILOAD-->IINC-->ISTORE。
  CAS(Compare And Swap)操作具备原子性
  实现线程同步的方式有很多,比如同步方法、锁、阻塞队列等。
Volatile
  happen-before:先从happen-before了解线程操作的内存可见性。把happen before定义为方法hb(a,b)表示a happen before b。如果hb(a,b)且hb(b,c),那么能够推导出hb(a,c)。
即如果a在b之前发生,那么a对内存的操作b是可见的,b之后的操作c也是可见的。
  指令优化:计算机并不会根据代码顺序按部就班地执行相关指令。CPU处理信息时会进行指令优化,分析哪些取数据可以合并进行,哪些存数据动作可以合并进行。CPU拜访
一次遥远的内存,一定会到处看看,是否可以存取合并,以提高执行效率。
  happen-before是时钟顺序的先后,并不能保证线程交互的可见性。那什么是可见性呢?可见性是指某线程修改共享变量的指令对其他线程来说都是可见的,它反应的
是指令执行的实时透明度。先从Java内存模型说起:每个线程都有独占的内存区域,如操作栈,本地变量表等。线程本地内存保存了引用变量在堆内存中的副本。线程对
变量的所有操作都在本地内存区域中进行,执行结束后再同步到堆内存(主内存)中去。在这个操作过程中,该线程对副本的操作,对于其他线程都是不可见的。
volatile的英文本义是挥发、不稳定的,延伸意义为敏感的。当使用volatile修饰变量时,意味着任何对此变量的操作都会在主内存中进行,不会产生副本,以保证共享
变量的可见性,局部阻止了指令重排的发生。它只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,一定会产生线程安全问题。如果是一写多读的并发场景,
使用volatile修饰变量则非常合适。volatile一写多读最典型的应用是CopyOnWriteArrayList,它在修改数据时会把整个集合的数据全部复制出来,对写操作加锁,修改完成
后,再用setArray()把array指向新的集合。使用volatile可以使线程尽快地感知array的修改,不进行指令重排,操作后即对其他线程可见。
源码如下:

public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
 /** The array, accessed only via getArray/setArray. */ 真正存储元素的数组
    private transient volatile Object[] array;

    final void setArray(Object[] a) {
        array = a;
    }
}

在实际的业务中,如果不确定共享变量是否会被多个线程并发写,保险的做法是使用同步代码块来实现线程同步。另外,因为所有的操作都需要同步给内存变量
所以volatile一定会使线程的执行速度变量,故要慎重定义和使用volatile属性。
  信号量同步
  信号量同步是指在不同的线程之间,通过传递同步信号量来协调线程执行的先后次序。基于时间维度的CountDownLatch和基于信号维度的Semaphore。
  CountDownLatch

public class CountDownLatch {
    /**
     * Synchronization control For CountDownLatch.
     * Uses AQS state to represent count.
     */
    private static final class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 4982264981922014374L;
        Sync(int count) {
            setState(count);
        }
        int getCount() {
            return getState();
        }
        protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }
        protected boolean tryReleaseShared(int releases) {
            // Decrement count; signal when transition to zero
            for (;;) {
                int c = getState();
                if (c == 0)
                    return false;
                int nextc = c-1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }
    }

    private final Sync sync;
...
}

可以看到其和ReentrantLock类似,都是依赖AQS中的可见性变量state。
  CountDownLatch:倒数计数器,比如日常开发中经常会遇到需要在主线程中开启多线程去并行执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的场景,它的内部提供了一个计数器,再构造闭锁时必须指定计数器的初始值(state),且计数器的初始值必须大于0。另外它还提供了一个countDown方法来操作计数器的值,(在子线程中)每调用一次countDown方法计数器会减1,直到计数器的值减为0(类似于获取到了锁),所有因调用await方法而阻塞的线程都会被唤醒。
  
  Semaphore:CountDownLatch是基于计数的同步类。在实际编码中,可能需要处理基于空闲信号的同步情况。

public class Semaphore implements java.io.Serializable {
    private static final long serialVersionUID = -3222578661600680210L;
    /** All mechanics via AbstractQueuedSynchronizer subclass */
    private final Sync sync;
    /**
     * Synchronization implementation for semaphore.  Uses AQS state
     * to represent permits. Subclassed into fair and nonfair
     * versions.
     */
    abstract static class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 1192457210091910933L;
        Sync(int permits) {
            setState(permits);
        }
        final int getPermits() {
            return getState();
        }
    ...
    }
  // 默认使用非公平锁
    static final class NonfairSync extends Sync {
        private static final long serialVersionUID = -2694183684443567898L;

        NonfairSync(int permits) {
            super(permits);
        }

        protected int tryAcquireShared(int acquires) {
            return nonfairTryAcquireShared(acquires);
        }
    }
  // 构造方法
    public Semaphore(int permits) {
        sync = new NonfairSync(permits);
    }
...
}

使用Semaphore的构造方法指定同时处理的线程的数量,只有在调用Semaphore的acquire()成功后,才可以往下执行,完成后执行release()释放持有的信号量,下一个线程就可以马上获取这个空闲信号量进入执行。
Semaphore的release()和CountDownLatch的countDown方法相同。
acquire()方法在直到有一个信号量空闲时,才会执行后续的代码,否则,将一直阻塞。可以理解为Semaphore允许有创建对象时在构造中指定的锁的数量,当锁有空闲时,线程就可以拿到锁,否则将一直等待。拿到锁的线程执行完毕后释放锁。
countDown和release都是使state减1。

线程池:
  线程池的好处:线程使应用能更加充分利用CPU、内存、网络、IO等系统资源。线程的创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间。
在线程销毁时需要回收这些系统资源。因此频繁的创建和销毁线程会浪费大量的系统资源,增加并发编程风险。另外,在服务器负载过大的时候,如何让新的线程等待或者
友好地拒绝服务?这些都是线程本身无法解决的。所以需要通过线程池协调多个线程,并实现类似主次线程隔离、定时执行、周期执行等任务。线程池的作用包括:
  1):利用线程池管理并复用线程、控制最大并发数等
  2):实现任务线程队列缓存策略和拒绝机制
  3):实现某些与时间相关的功能,如定时执行、周期执行
  4):隔离线程环境。通过配置两个或多个线程池,将一台服务器上较慢的服务和其他服务隔离开,避免各服务线程相互影响。
参数说明:
1、corePoolSize  表示常驻核心线程数,如果大于0,则即使执行完任务,线程也不会被销毁。因此这个值的设置非常关键,设置过小会导致线程
  频繁地创建和销毁,设置过大会造成浪费资源
2、maximumPoolSize  表示线程池能够容纳的最大线程数。必须大于或者等于1。如果待执行的线程数大于此值,需要缓存在队列中等待
3、keepAliveTime  表示线程池中的线程空闲时间,当空闲时间达到keepAliveTime值时,线程会被销毁,避免浪费内存和句柄资源。在默认情况下,当线程池
  中的线程数大于corePoolSize时,keepAliveTime才起作用,达到空闲时间的线程,直到只剩下corePoolSize个线程为止。但是当ThreadPoolExecutor的
  allowCoreThreadTimeOut设置为true时(默认false),核心线程超时后也会被回收。(一般设置60s)
4、TimeUnit  表示时间单位,keepAliveTime的时间单位通常是TimeUnit.SECONDS
5、workQueue  表示缓存队列。
6、threadFactory  表示线程工厂。它用来生产一组相同任务的线程。线程池的命名是通过给threadFactory增加组名前缀来实现的。在用jstack分析时,就可以知道
  线程任务是由哪个线程工厂产生的。
7、handler   表示执行拒绝策略的对象。当超过workQueue的缓存上限的时候,就可以通过该策略处理请求,这是一种简单的限流保护。友好的拒绝策略可以是如下
  三种:
    1):保存到数据库进行削峰填谷。在空闲时再取出来执行
    2):转向某个提示页面
    3):打印日志

总结使用线程池需要注意以下几点:
1、合理设置各类参数,应根据实际业务场景来设置合理的工作线程数
2、线程资源必须通过线程池提供,不允许在应用中自行显式创建线程
3、创建线程或线程池请指定有意义的线程名称,方便出错时回溯
4、线程池不允许使用Executors,而是通过ThreadPoolExecutor的方式来创建,这样的处理方式能更加明确线程池的运行规则,规避资源耗尽的风险。
如创建线程池例子:

    /**
     * 创建一个用于发送邮件的线程池,核心线程数为1,最大线程数为5,线程空闲时间为60s,拒绝策略为打印日志
     */
    private static final ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(50), new CustomThreadFactory("redeemSendMail"), new RejectedExecutionHandler() {
        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            // 只打印日志,什么都不做
            LOGGER.error("Task{},rejected from{}", r.toString(), executor.toString());
        }
    }); 

ThreadLocal:
ThreadLocal是每一个线程私有的,每一个线程都有独立的变量副本,其他线程不能访问,所以不存在线程安全问题。也不会影响程序的性能。ThreadLocal对象通常都是
由private static修饰的,因为都需要复制进入本地线程,所以非static意义不大。但是ThreadLocal无法解决共享对象的更新问题。

ThreadLocal有个静态内部类ThreadLocalMap,而ThreadLocalMap还有一个静态内部类Entry【 static class Entry extends WeakReference<ThreadLocal> 】,ThreadLocal的ThreadLocalMap属性的赋值是在createMap方法中进行的。ThreadLocal和ThreadLocalMap有三组对应的方法:get、set和remove;在ThreadLocal中只对它们做校验和判断,最终的实现会落在ThreadLocalMap上。Entry继承自WeakReference,没有方法,只有一个value属性【Object value;】

ThreadLocal可用来为每个请求存储上下文,如session;

ThreadLocal的副作用:
为了使线程安全的共享某个变量,JDK提供了ThreadLocal,但是ThreadLocal有一定的副作用,主要问题是会产生脏数据和内存泄漏。这两个问题通常是在线程池中的线程
使用ThreadLocal引发的,因为线程池有线程复用和内存常驻两个特点。
  1、脏数据,线程复用会产生脏数据。由于线程池会重用Thread对象,那么与Thread绑定的类的静态属性ThreadLocal变量也会被重用。如果在实现的线程run()方法体中没有显式地调用remove清理线程相关的ThreadLocal信息,那么倘若下一个线程不调用set()设置初始值,就可能get()到重用的线程信息。包括ThreadLocal所关联的线程对象的value值。
  2、内存泄漏,在源码注释中提示使用static关键字来修饰ThreadLocal(放在常量池中了)。在此场景下,寄希望于ThreadLocal对象失去引用后,触发弱引用机制来
回收Entry的Value就不现实了。
所以,解决上面两个问题的方法就是在每次用完ThreadLocal时,必须要及时调用remove()方法清理。
文章来源于网络。
感谢大家阅读,欢迎大家私信讨论。给大家推荐一个Java技术交流群:473984645里面会分享一些资深架构师录制的视频资料:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多!
推荐大家阅读:
Java高级架构学习资料分享+架构师成长之路​
个人整理了更多资料以PDF文件的形式分享给大家,需要查阅的程序员朋友可以来免费领取。还有我的学习笔记PDF文件也免费分享给有需要朋友!

上一篇下一篇

猜你喜欢

热点阅读