Java知识实践—基础篇(四)

笔记

Posted by Jingming on October 25, 2018

此篇总结Java的多线程。

https://www.bilibili.com/video/av11076511/ 多线程分三大块:同步和通信、同步容器、线程池。 JAVA线程之间通信主要靠的是共享内存。

一、基本概念

多线程底层原理简单理解

多线程实现类似多进程实现,多线程实现了并发的执行某个进程内多段代码逻辑,且可以共享进程的一些资源。线程之间是调度算法来切换的,在cpu只有一个核的时候,看起来是并发,但是实际上是一个线程执行一点点,就切到另一线程执行一点点的结果;只有在cpu有多核的时候才能称为并行。 参考:https://segmentfault.com/q/1010000004100707 多线程的底层实现,是cpu和操作系统内核配合的结果,内核向用户提供多线程API,JAVA只是封装了该API,便于让用户使用而已。

理解同步、异步、阻塞、非阻塞、同步IO、异步IO

https://www.zhihu.com/question/19732473 这些概念的两个参与者,就是调用者和被调用者。同步异步关注的是被调用者,阻塞非阻塞关注的是调用者。 所谓同步,就是指要求被调用者把真正的返回值返回给调用者,以形成一种“状态同步”的概念,也就是要求被调用者在自己不知道计算结果的时候不会返回;所谓异步,就是“状态不同步”,被调用者在自己不知道结果的时候,就会先告诉调用者一个假结果(空结果)。所谓阻塞不阻塞,是指调用者在调被调用者后的状态,如果调用者会死等结果(没真结果没法继续运行,挂起自己),那就是阻塞,如果不死等(没结果也没关系,下面的代码可以不受影响的继续执行),那就是非阻塞。 异步的时候,调用者和被调用者会有相应的机制去返回调用结果(例如被调用者把执行状态或者结果放到调用者的事件处理队列中,调用者在执行完当前任务后或者时间到了就去检查事件队列)。 例子:人使用水壶烧水(人调用水壶的功能),如果人一直盯着水壶烧水,等烧开后关火,那么就是同步阻塞;如果人烧水时候去干别的事,但是每隔一段时间会过来看看水开没开,开了就关火,那么就是同步非阻塞;如果人烧水的时候,如果水壶是电水壶,且可以烧开后自动关闭,那么这个人可以在烧水后就去做其他事,然后水壶只需要在完成工作后发出声音通知一下人就可以,这种就是异步非阻塞;如果一个人,盯着这种“智能”的电水壶,直到水烧开,这种就是异步阻塞。 显然,异步阻塞是没有意义的;同步非阻塞意味着,没有异步机制(被调者不能单独完事,要依赖调用者来收尾),而调用者也不死等,也就是说,调用者需要每隔一段时间主动去检查一下是否调用返回真的结果了。 异步非阻塞IO的例子:调用者调用IO读文件的时候,已经知道文件大小,因此分配一块区域,等着IO(内核)来主动填满这个区域,然后发信息给自己,自己则在调用IO后,马上执行自己下一步的代码去了。 同步非阻塞的例子:QT框架下,点击按钮触发一段network下载代码,此时主界面主线程并不会卡死,而是可以继续执行,例如界面切换或者点击另一个按钮,另外,下载代码执行的时候,可以向主线程的事件队列中放入自己的状态信息,此时主界面策略是非阻塞,循环检查事件队列的时候,就可以把下载信息及时的更新到界面上。 多路复用(IO multiplexing)机制:select、poll、epoll。这里的调用者是用户程序,被调用者是指IO工具,也可以认为是内核。多路复用三种方式本质上是同步IO,也就是说,不存在异步机制(例如IO主动把相应的内核数据拷贝回用户程序空间)。也就是说,应用程序把自己的fd传入内核,内核先把磁盘数据加载到内核内存中,然后应用程序来把内核内存的数据拷贝到应用程序内存。 举个例子,好比是用户,调用专业人员来做烧水的任务。首先,假设用户都是很关心水开没开的,也就是说用户是同步的,其次,规定 用户选择等待(阻塞),也就是说用户有策略是去休眠,等着水烧开后被唤醒;io多路复用指的就是专业人员在烧水时候,不是只负责一位用户、一个水壶,而是可以同时看着一堆水壶烧水,哪个好了,就立即通知用户来处理。 (a)select: 调用者调用IO后自己被阻塞(把自己挂起,放到等待队列中),在被调用者IO没有能力主动拷贝回数据的时候,如何提升效率呢?答案就是,多个调用者被阻塞,然后只要是被调用者已经将数据从磁盘读到内核后,就会唤醒相关的调用者过来拿。如何知道内核数据好了呢?原理就是内核中准备好了空间,然后和调用者fd相对应,然后内核会轮番的查询fd对应的数据好了没有。 (b)poll poll和select基本一样,唯一区别是不限制调用者,也就是fd的数量,因为使用的是链表。 (c)epoll select和poll机制中,内核轮询查询数据有没有准备好的方式比较简单粗暴,所以效率低。epoll做的工作就是更好的发现那些fd对应的数据块已经准备好了。也就是说,epoll不是简单的轮询,而是对fd对应的数据进行划分,例如一些高频的,应该先检查,例如数据块小的也许更容易先准备好,也应该先检查。epoll还使用了红黑树来更快的查找数据块准备好的fd。 epoll与select和poll相比,优化了查询fd对应数据块准备好这种情况,也就是加入了中间层。 异步IO的实现理解:异步IO的本质还是使用操作系统的多线程库,但是异步IO的好处是完全的并发执行代码,也就是说该部分的代码不会直接

二、线程状态

理解线程进入阻塞态的几种情况

(1) wait wait方法属于Object类,所以所有对象都有这个方法,该方法作用是,让拥有该对象的锁的线程进入阻塞,并释当前线程的对该对象的锁。wait一般和while配合使用,例如生产者,在缓冲区满的时候阻塞自己,如果不使用while,那么如果假设一个消费者消费了并通知了所有生产者线程,如果此时使用if,那么导致的结果多个生产者可能同时向唯一的坑里面放入产品。 notify是唤醒了由于调用相应wait方法而阻塞的线程中的一个,恢复成就绪状态,但不保证该线程一定获得锁。notify不会让执行notify的方法释放锁。 实际编程中要使用notifyall,例如生产者消费者,如果生产者生产后只是notify,那么唤醒的也许还是生产者。 (2) join 当前线程启动子线程t(t.start())后,可以使用t.join()来阻塞自己,等待子线程t的结束运行后转为就绪态。 (3)sleep或者等待用户输入 (4)线程自己进行IO操作 (5)synchronized 理解:线程使用wait来阻塞自己的原因是知道自己会和其他线程共享某项资源,主动让出自己使用权的意思,被notify后,又要继续进入抢锁的状态。 synchronized是对象知道了自己是会被多线程使用时候加的锁,这样线程在发现锁被占用的时候,会自己阻塞自己。 sleep、等待用户输入、join的意思是,抱着锁资源睡,也就是说,线程虽然拿到了一些资源,但是还需要等待一些其他资源,否则无法继续进行。

线程进入就绪态的情况

(1)yield (2)时间片用完了 个人认为yield是说,当前线程的优先级没有那么高,可以给与自己优先级差不多的线程执行的机会。

停止java线程

不可以直接使用stop,因为stop会立即停止线程。正确方法是使用全局布尔变量,在想停止线程的时候,把while条件判断的全局布尔变量修改掉。 也不可以使用interrupt。interrupt的本质还是修改全局的isInterrupt变量使得子线程停止,问题是interrupt不一定停止,如果不停止,那么后面一旦有修改线程的操作,会被interrupt的异常打断。

###三、争用条件 race condition 多个线程同时访问共享的同一数据导致数据被破坏。例如,账户转账,代码有两行,第一行把第一个账户减去10元,第二行把第二个账户加上10元。如果执行第一行的时候,线程进行切换了,如果切换后的线程对第二个账户信息进行操作,那么会产生不一致。更简单的例子是:两行代码,第一行是c–,第二行print c。这样会打出很多重复数字。另外,很多方法都不是原子性的,例如java linkedlist的remove方法。例如,在 linkedlist大小为1的时候,却又多个线程去拿其中的内容,会造成越界问题。即使有vector这种原子性的remove函数,但是size()和remove()两个函数的调用之间的部分也不是原子的。 解决的第一个方法是加synchronized锁,这样线程之间就只能互斥的访问转账这一方法,方法内部的访问是原子性的。每个对象都有锁属性,有时为了方便就使用包含方法的对象本身,也就是this。synchronized加在方法上,说明要访问当前对象的该方法前,一定要获得该对象的锁,也就是简写版的synchronized this。 synchronized(锁对象o) { // 执行该代码块必须先拿到对象o的锁 //执行之前必须获得锁,执行完之后在释放锁 } 锁住静态方法:使用synchronized(类名.class)。 注意synchronized的范围,是当前锁住代码块执行前需要获得锁,注意锁不是锁住拥有锁的对象,不加synchronized的其他方法仍然可以执行。拥有锁的对象属性变化,锁不变,但是锁的对象类型变了,比如引用类型改变,或者引用指向了一个新对象,那么锁变化(之前锁失效),这点很好理解,因为对象的锁属性是在堆中的,而不是栈上的。 synchronized出现异常,锁就会被自动释放。 在一个synchronized方法A内调用另一个synchronized方法B的时候,如果两个方法使用同一把锁,是可以调用的,因为相当于当前线程在检查是否有方法B的对象锁的时候发现是有的。或者子类synchronized方法调用父类synchronized方法。

多线程常用模型

(1) 生产者-消费者 (2) 读者写者 (3) Future模型 (4) Worker Thread模型

runnable、callable、future、futureTask

https://blog.csdn.net/bboyfeiyu/article/details/24851847 callable 和runnable的区别是:(1)callable call函数可以有返回值,(2)call可以抛出异常。run必须函数内处理异常。 executor是对callable 和runnable的调度容器。 future的作用是对callable 和runnable执行状态进行操作的接口,其中get方法可以得到执行结果(以阻塞的方式)。 futuretask等于runnable+future。

启动线程

new Thread(t::m1, “m1”).start()等价于 new Thread(new Runnable()) { @Override public void run() { t.m1(); } }

脏读

例如对写加锁,不对读加锁,读有时候也许会读出未写好的数据,这就是一种脏读。如果对读也加锁,那么就不会出现这种情况了,因为写在读的时候访问不到数据。

线程安全和可重入

线程安全: 指的是函数在多线程环境中能够正常工作。首先,函数不正常工作是由于共享了外部变量,例如函数对一个类static变量进行了写操作,在多线程环境下,如果没有线程安全机制,那么就会出现读写不一致。也就是说,如果不共享外部变量,该函数将会是线程安全的,如果共享变量且线程安全,那么需要线程安全机制,例如synchronized修饰的方法,首先该对象类型就注册到系统中了,也就是说,会对调用方法的线程进行锁检查的逻辑,当一个方法被调用的时候,系统把对象的锁属性锁上。 可重入:比线程安全要求高,重入是指函数自己在调用过程中被中断后,又被从头开始调用函数,也就是说,当前函数如果依赖堆中的变量,那么就可能在重入时候发生错误。反之,如果不依赖堆中变量,那么才可能是可重入的。

volatile变量

volatile变量在各语言中的定义差不多,但是实现起来也许不同编译器有不同。 volatile的作用就是说被修饰的变量,编译器不能随心所欲去优化,编译器也许会认为该变量可以优化(编译器觉得该变量在代码过程中无法变化),而实际上,该变量是可以随时变化的(例如可以被其他进程改变)。 JAVA里面,volatile也意味着不能线程局部缓存该变量,也就是该变量对所有线程可见。 JAVA里面jmm规定了很多happens before的原则来判断一个变量是否从逻辑上对另一个变量有影响。jmm不同thread内部是有自己的缓存区的,如果不使用volatile,那么就是相当于缓存不知道其中变量需要被更新。 volatile变量不能和synchronized那样保证原子性(两者都保证可见性),例如对一个volatile变量进行++操作,++操作是不具备原子性的,所以也许会出现race condition。 但是volatile轻量、效率高。 参考:https://blog.csdn.net/chosen0ne/article/details/10036775 https://zh.wikipedia.org/wiki/Volatile%E5%8F%98%E9%87%8F

countdownLatch门栓

作用是减少wait和notify复杂的交互。countdown的意思是向下数,一开始设置数量n,每调用一次countdown方法就减一,减到0的时候就门栓打开(执行后面的代码)。使用await方法来栓门,表明在此处等待count减为0。

互斥锁和信号量的区别

mutex(synchronized)和信号量的区别就在于互斥锁有一种临界区的概念,这个临界区只能一个线程进入,所谓解铃还须系铃人,也就是说,获得临界区锁的线程自己还要负责释放临界区的锁。这个和上厕所差不多,上厕所自己锁门,自己开门。 信号量不需要自己减少自己:信号量只是红绿灯,一个线程访问资源之前访问信号量,如果信号量不满足条件,线程会阻塞,等待满足条件的时候,其他人主动来唤醒它。至于灯为什么是红的,和自己要不要经过是没有什么必然的关系的(但是锁机制就存在必然关系)。也可以理解为生产者和消费者,消费者负责消费,有东西就消费,至于东西是谁生产的并不关心。这种生产和消费之间隐含着一种同步的关系:只有一个线程先使得某种变量到达某种值了,另一个线程才会继续执行。 例子:进程读磁盘,进入睡眠,等待中断读取盘之后来唤醒他。

synchronized和reentrantLock的区别

reentrantLock也是锁,只是锁起来更加灵活,且reentrantLock可以trylock,拿不到锁,也可以继续执行不需要拿锁才能做的操作,例如读操作。 还可以使用lockInterrupted()来取代lock(),这样好处是可以相应中断,也就是其他线程可以使用interrupt来打断(停止)自己,且同时抛出异常。 还可以设置reentrantLock true表示公平锁,也就是按照等待时间来进行线程调度,等待时间长的线程优先获得锁。

ThreadLocal

ThreadLocal保证线程拥有自己的变量,不会和其他人共享,例如自己拥有数据库连接,可以保证自己在读写数据时候不被其他线程所影响,从而保证原子性。 原理是ThreadLocal会在内存创建以thread为key的map,然后这样就和其他thread区分开。 Cookie是ThreadLocal中的一种变量,每个网站都有自己的Cookie。 ThreadLocal.get()可以把map的所有信息都读取出来。

多线程容器

初级并发使用:hashtable (等价于使用Collections.sychronizedMap(map)来使得map成为加锁的容器) 并发比较高的情况下使用:concurrentMap,如果要求有序,那么使用concurrentSkipListMap copyOnWrite: 写容器的时候,也就是添加元素的时候,先复制一份,然后在复制的容器中添加,然后把指向原来容器的引用指向新容器。这样,读操作不需要进行加锁控制就可以并发的读,因为当前指向的容器是不变的。 假设现在使用10个线程向list中加数据,不加同步的条件下,如果使用的是arraylist,那么它add之后,可能add的数量不够,因为arraylist的add不是线程同步的,为了理解,假设add分为两步,一步是找到arrayList的最后一个元素,另一步是在最后一个元素后添加一个新元素,显然,如果不加同步的话,多线程读到的最后一个元素可能是没有更新后的元素。 而用vector或者是copyOnWriteArrayList就可以,因为是线程安全的容器,单独操作是原子的。 concurrentLinkedQueue: blockingQueue: put方法,在满的时候会让调用者进入阻塞,take方法,在空的时候会让调用者阻塞。 ArrayBlockingQueue和LinkedBlockingQueue的区别:前者规定了大小,后者大小和内存一样。 DelayQueue:等待时间最长的排前面,容易出去,而且可以规定等待至少多少时间才能出队列。可以用来执行定时任务。 LinkedTransferQueue:生产者生产产品后,直接找消费者,如果有消费者,那么直接交给他。用于高并发。 SynchronizedQueue:特殊的TransferQueue,必须立即消费。

线程池

Executor接口,里面有execute方法,里面可以传入方法具体实现。 ExecutorService接口继承Executor,里面多了submit方法。submit比起execute方法来支持返回值。 Executors和Executor的关系类似于Collections和Collection。 六种线程池:fixed、cached、single、schedule、workstealing、forkjoin。 自定义线程池:继承ThreadPoolExecutor。

wait()和sleep()区别

wait是释放锁去睡觉,sleep是抱着锁睡觉。

Thread类中start() 和run()方法的区别

区别就是:start是启动一个线程,但是线程不是立即启动的,一开始也许是处于阻塞态的。run是立即运行代码,但是并没有多线程效果,也就是说,start内部会自己调用run。

生产者消费者编码

大致思路:生产者生产出的产品存入一个缓存区,且这个缓存区的放入和取出两个方法需要加锁,缓存区有大小。生产者调用放入方法的时候,如果发现缓存区满了,那么就需要在放入方法内部使用wait方法让当前占用缓存区的生产者线程阻塞,等待其他线程让他醒过来继续干活,当他醒过来的时候,他会生产一个产品并去唤醒一个消费者来消费;如果一开始发现缓存区没有满,那么他生产之后会去唤醒一个消费者来消费。 生产者消费者对象实现runnable接口,并重写run方法。