美文网首页
java多线程详解

java多线程详解

作者: 奔跑的Robi | 来源:发表于2019-08-16 00:03 被阅读0次

对于Java多线程,首先需要理解的是其内存模型,在Java虚拟机规范中规定,所有的数据都存储在主内存中,而每个线程则有一个工作工作内存,线程需要进行数据处理时,都是先从主内存中读取数据到自己的工作内存,然后进行相应的操作,最后将操作结果赋值到主内存中。但是这里存在的问题在于,如果工作内存中的数据没有及时更新到主内存中,那么其他的线程从主内存中读取数据时,读取到的数据就是更新之前的数据,这样在多线程环境中就会造成数据的不一致问题。

   为了解决这两个问题,Java定义了两种类型的锁操作,一种是使用synchronized修饰的重量级锁,一种是使用volatile修饰的轻量级锁,严格的说volatile并不能算是锁,因为其只保证了对数据操作的可见性,而没有保证原子性,只不过Java通过使用CAS算法来将其作为一个锁来使用了。Java虚拟机规范关于锁规定了两个happens-before规则:①对于一段代码,其解锁操作一定发生在随后对其进行加锁的行为之后;②对于volatile修饰的变量,对其进行的写入操作一定发生在随后对其进行的读取操作之前。这两个happens-before规则其实根本意思就是表达了如果一个线程A在一段加锁的代码中进行了某些操作,然后释放了锁,此时另一个线程B获取到了锁,然后进行该线程的操作,那么Java虚拟机就能够保证A线程进行的操作一定都是发生在B线程之前,这样就能保证A对共享数据的操作结果是能够为B所见的,这也就是synchronized和volatile能够保证对其修饰的共享数据的可见性的原因。

    对于synchronized实现的锁,其是重量级的,对于被锁定的代码,每次只有一个线程能够访问,并且Java虚拟机能够保证执行结束的结果都已经刷新到了主内存中,然后另一个线程从主内存中读取数据时就是最新的了。对于volatile而言,其主要有两个特性,一个是可见性,一个是禁止了指令重排序。这里的禁止指令重排序指的是对于volatile变量的读取或写入操作之前的指令一定不会被重排序到读取或写入操作之后。需要注意的是volatile变量是不能保证原子性的,这里比较典型的就是对基本数据类型int,long和double数据的读取。现代的处理器主要有32位和64位两种,但即使是64位处理器,其也是被拆分为两个32位的操作来进行的,也就是说处理器处理数据的基本单元是32位,对应的也就是int类型的长度,而long和double是64位的,因而会被拆分为两个32位的单元来进行处理。总结来说,就是由于处理器的支持,对于一个int类型的变量,其读取和写入操作一定是原子的,而volatile则能够保证一个变量的可见性,那么如果一个int类型变量使用volatile修饰之后,其就同时具有了原子性,可见性和禁止指令重排序的特性。

   实际上,在java.concurrent包中的多线程工具类的核心实现原理就是对一个volatile修饰的int类型变量state的操作。其核心实现算法是CAS算法,也就是compare and set,该算法需要传入三个参数,一个需要操作的变量地址,一个当前线程认为的值,一个是当前线程想要将其更新为的值。比如对于Java里的ReentrantLock,每个线程在尝试获取锁时都是将当前线程认为的值设置为0,然后其要修改的目标值是1,这样主要一个线程获取到了锁,那么state就不会为0,对于其他的线程而言,其compare and set就始终不会成功,而对于获取了锁的线程,其在执行完锁定代码之后会释放锁,这个释放锁的过程就是将state的值重新设置成0,这样,其他的线程进行compare and set操作的时候就会有一个是成功的。实际上,ReentrantLock在进行锁竞争的时候并不是像上面说的这样一直都在无限for循环中一直进行CAS操作,而是在无限for循环中进行一次尝试,如果没获取到锁,其就会进入一个等待队列中,并且Java虚拟机就会将其搁置起来,以释放CPU资源,当正在使用锁的线程使用完锁之后,其会唤醒队列中第一个等待的线程,其还是会在无限for循环中再次使用CAS算法获取锁,而这一次是可以获取到的。这里关于ReentrantLock主要有两个特性:可重入性和公平性的设置。可重入性指的是同一个线程能否多次获取同一个锁,而公平性指的是线程在获取锁的时候与其他的线程是直接竞争还是按照尝试获取锁的时间顺序来依次获取锁。对于可重入性,其实现比较简单,就是判断当前线程如果是尝试再次获取锁,那么就将state值加1,也就是说记录锁的标志state的值记录了当前线程重复获取当前锁的次数,而最后当前线程获取了多少次锁,其就会释放多少次。对于公平性和非公平性,其实就是当一个线程尝试获取锁的时候是直接进入等待队列还是与当前正在尝试获取锁的线程进行竞争之后再进入等待队列。

   java.concurrent包中提供的主要类有ReentrantLock,CountDownLatch,CyclicBarrier,ThreadPoolExecutor,ForkJoinPool,ThreadLocal,FutureTask以及一些其他的一些队列。这里的ReentrantLock,CountDownLatch和CyclicBarrier的作用和实现比较简单,ReentrantLock的原理上面已经讲到了,而CountDownLatch就是设置了一个倒数的机制,其构造函数需要传入需要倒数的次数,首先主线程会执行await()方法,而其他的用于执行某些任务的线程在每次执行完任务之后就会调用CountDownLatch.countDown()方法,那么其就会倒数一次,直到最后所有的任务执行完成之后,倒数计数器的值为0,这个时候主线程就会被唤醒,然后继续执行后续操作,CountDownLatch主要的作用在于可以将多个任务进行并行操作,而任务执行完成之后再对任务的结果进行汇总。根据这里的讲解,其实CountDownLatch的实现原理也比较简单,就是在构造对象时设置state值为传入的值,然后每次执行countDown()方法时就使用CAS算法将state减一,直到最后一个线程将其更新为0之后由该线程唤醒主线程继续往下执行。对于CyclicBarrier,在构造函数中也会为其传入一个初始值,这个值也是直接设置到state中,其只提供了一个await()方法,每当一个线程执行了await()方法之后,如果state值还没有倒数到0,那么该线程就会进入等待状态,直到所有的线程都执行完各自的任务之后,最后一个线程就会唤醒所有等待的线程,这样就可以保证这些线程在同一起跑线上开始执行接下来的任务。关于CyclicBarrier很典型的一个用法是在进行游戏加载的时候,比如五个人协作的游戏,在进行游戏装载阶段,必须要等待五个人都加载完成之后才能开始进行游戏。

   对于ThreadPoolExecutor,ForkJoinPool,ThreadLocal和FutureTask,它们的实现相对复杂一些。ThreadPoolExecutor,本质上是一个线程池,其内部维护了一定数量的线程,这里可以主要讲解一下它的调度策略。在初始状态时,每当push一个任务进来时,线程池中是没有线程的,此时就会为其创建一个线程执行任务,当线程数达到核心线程数之后,再有任务进来时,如果没有可用的核心线程,那么就会将当前任务添加到任务队列中,当任务比较多,任务队列也填满了之后,如果设置的最大线程数大于核心线程数,那么就会继续创建线程执行任务,直到任务队列中存满了任务,并且所有的线程都在执行任务,这个时候就会有一个拒绝策略的问题,这里主要有四种策略:AbortPolicy,CallerRunsPolicy,DiscardPolicy和DiscardOldestPolicy。这四种策略的意思是拒绝当前任务,由调用方自己执行该任务,忽略队列中最后添加的任务而添加当前任务,忽略队列中最早添加的任务而添加当前任务。这里的调度策略中还需要说明的一点就是,对于核心线程与最大线程数的区别,对于核心线程,只要其创建了,那么线程池中始终是会维护这么多线程的,而对于最大线程数,只要多出来的线程没有任务可以执行,那么在一定时间之后其就会被销毁掉。对于ForkJoinPool,这个线程池的执行效率非常之高,因为其使用了工作窃取算法,其内部也是有一个线程池,但是其每个线程都维护了一个队列,不像ThreadPoolExecutor,其只维护了一个队列由多个线程进行竞争。对于ForkJoinPool,当前线程如果执行完了当前队列中的任务,那么其就会去其他的线程维护的队列中查看是否有未执行完的任务,如果有的话就会帮助该线程执行任务,而且当前线程偷取的任务并不是与目标线程进行竞争,而是目标线程在队列头获取任务,而窃取任务的线程只在队列尾部获取任务,只有在最后只剩下一个任务的时候两个线程才会出现竞争。ForkJoinPool非常适合那种有大量的任务,并且任务执行时间相对不长的场景使用。

   对于ThreadLocal对象,其主要的设计理念就是对于某些数据,其不需要进行共享,而只需要和当前线程绑定即可,对于这样的数据,也就不会存在多线程环境竞争的问题。在每个线程中都有一个ThreadLocalMap变量,当前线程使用ThreadLocal保存的数据都存储在该Map中,由于该Map是和当前线程绑定的,因而也就达到了将变量与当前线程进行绑定的目的。

   对于FutureTask,其使用比较简单,但是其设计理念是比较有趣的。其作用在于对于一些比较耗时的任务,在一开始时就将任务封装到FutureTask中,让其执行,然后主线程进行其他的动作,当主线程执行到必须要FutureTask的执行结果的位置时,再调用其get()方法获取结果,此时,如果FutureTask任务还未执行完,get()方法就会阻塞主线程,直到其执行完,如果FutureTask任务执行完成,那么get()方法就可以直接得到结果。从这里可以看出,FutureTask主要是用于将比较耗时的操作在前期就进行封装,从而达到一种并行执行的目的。

乐观锁:认为对数据的操作大部分都是读操作,在数据库中,每条数据都有一个版本号,每次对该数据的修改都会将版本号增一,如果一个线程尝试更新一个数据,那么它首先会读取这条数据,包括版本号,然后乐观的认为数据库在此期间没有修改过数据,从而拿着此版本号与数据库的版本号进行比较,如果版本号一致,则更新这条数据,如果不一致,则尝试再次读取并更新。乐观锁适用于读比较多,而写操作比较少的场景。

悲观锁:在每次进行数据读取并更新的过程中,都认为会有其他的线程会来修改该数据,因而在数据还未处理完成之前会一直对该数据添加独占锁。

相关文章

  • java多线程

    java多线程详解

  • Android中多线程的使用

    说完了《Java中的多线程详解》之后,今天来说一下Android中的多线程。 先来列举下Android下启用多线程...

  • 扣丁学堂带你深入理解Java多线程核心知识之跳槽面试必备技能

    今天扣丁学堂Java培训老师给大家介绍一下关于深入理解Java多线程核心知识之跳槽面试必备技能详解,首先多线程相对...

  • 多线程(让你学习怎么卖车票)

    本文参考: Java 多线程详解(三)------线程的同步作者YSOcean 模拟场景:火车站卖票,50张票,分...

  • Java 锁机制详解(二)volatile

    上接 Java 锁机制详解(一)synchronized 一、 多线程隐患 1. 内存可见性 在赋值变量时,会经历...

  • iOS多线程详解

    iOS多线程详解

  • java多线程详解

    对于Java多线程,首先需要理解的是其内存模型,在Java虚拟机规范中规定,所有的数据都存储在主内存中,而每个线程...

  • JAVA多线程详解

    1.前言 在JAVA内存模型(JMM)中已经讲解了为了提高处理器的效率,实现"高并发"的效果,在处理器和存储设备之...

  • Java 多线程详解

    一、线程与进程 在讲多线程之前,我们得先分清楚线程与进程的区别,当然这也是面试中常遇到的问题。 进程是资源(CPU...

  • Java多线程详解

    多线程作为Java中很重要的一个知识点,在此还是有必要总结一下的。 先看一下相关图例 1. Java线程具有五中基...

网友评论

      本文标题:java多线程详解

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