多线程拾遗
之前写过一些多线程或者JUC的文章,最近再看并发相关的内容,又有了一些新的理解,做一个补充。
一、Thread
1、Thread和Runable
验证守护线程:如果主线程不休眠,则不会等task中的内容输出,直接停止主线程
public class DaemonThread { public static void main(String[] args) throws InterruptedException { Runnable task = new Runnable() { @Override public void run() { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.printf("thread name ==== " + Thread.currentThread().getName()); } }; Thread thread = new Thread(task); thread.setName("test-thread-1"); thread.setDaemon(true); thread.start(); Thread.sleep(6000); } }
在上面代码中用到了Runable,实际上Runable是一个接口,Thread类本身就是实现了Runable接口,那么他们都有一个run方法,这也就能理解为什么上面代码中调用run方法是在当前线程中执行的原因。
在使用的时候,可以像上线的代码一样使用匿名内部类的方式,也可以使用如下继承的方式实现。
public class Runner1 implements Runnable { @Override public void run() { for (int i = 0; i < 100; i++) { System.out.println("进入Runner1运行状态——————————" + i); } } }
2、Thread类的相关方法
3、wait¬ify
4、Thread状态变更
Thread.sleep(long millis):不释放锁,但是释放CPU,是Thread的方法,进入TIMED_WAITING 状态
Thread.yield():和sleep比较类似,区别是sleep后,可以让所有其他线程获取执行时间,而yield只会让比当前优先级高的线程获取执行时间,也是进入TIMED_WAITING 状态
t.join()/t.join(long millis):当前线程调用其他线程的join方法,当前线程不会释放锁,但是调用了其他线程的wait方法,因此其他线程会释放锁,当其他线程执行完成或者到时间后,有jvm层面地层线程执行结束前调用notify。当前线程进入WAITING/TIMED_WAITING 状态。
obj.wait():释放锁、释放CPU,属于对象的方法
obj.notify() :唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll() 唤醒在此对象监视器上等待的所有线程。
5、Thread的中断和异常处理
线程内部自己处理异常,不溢出到外层,除非使用Future 封装。
如果线程被 Object.wait, Thread.join和Thread.sleep 三种方法之一阻塞,此时调用该线程的interrupt()方法,那么该线程将抛出一个 InterruptedException 中断异常(该线程必须事先预备好处理此异常),从而提早地终结被阻塞状态。如果线程没有被阻塞,这时调用 interrupt() 将不起作用,直到执行到wait/sleep/join 时,才马上会抛出InterruptedException。
如果是计算密集型的操作,可以分段处理,每个片段检查一下状态,判断是不是要终止
二、并发相关的特性
1、原子性:
对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。volatile 并不能保证原子性。
2、可见性:
对于可见性,Java 提供了 volatile 关键字来保证可见性。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。
3、有序性:
Java 允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。可以通过 volatile 关键字来保证一定的“有序性”(synchronized 和 Lock也可以)。
happens-before 原则(先行发生原则):
程序次序规则:一个线程内,按照代码先后顺序
锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
Volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出 A 先于 C
线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作
线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行
对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始
三、线程安全
1、synchrozied
synchrozied存在偏向锁、轻量级锁、重量级锁。
使用sychrozied可以锁方法、代码块,在锁代码块时,可以使用this或者单独定义一个对象进行加锁,区别就是锁的粒度和锁分离,例如对代码块相比方法加锁,锁的粒度会小很多,可以提高并发;而使用单独对象加锁相比使用this加锁,可以根据不同的情况不同的方法不同的代码块进行加锁,而不会导致所有的加锁都互斥。
public synchronized void incr1() { sum = sum + 1; } public void incr2() { synchronized(this) { sum = sum + 1; } } private Object lock = new Object(); public void incr() { synchronized(lock) { sum = sum + 1; } }
2、volatile
每次读取都强制从主内存刷数据,但是能不用则不用,可以使用Atomic原子类代替。
3、final
我们都知道使用final修饰类时,该类不允许被继承,修饰方法时,表示方法不能被重写,修饰局部变量时,表示变量不可被修改。
final修饰实例对象时,表示该实例只能被赋值一次,在执行完构造函数后,不允许再修改。
dinal修饰静态变量,也表示在执行完静态方法或静态代码块后就不允许修改,其实也就保证了线程安全。如果final修饰的是原生类型,其实就相当于该变量是个常量,虽然Java中没有常量关键字,但是其表达的常量的含义。常量的作用就是在编译的时候会直接将值赋给引用的对象。
写代码最大化用 final 是个好习惯
四、线程池
相关内容可以看之前写的文章:
这里说一些之前没有梳理的点:
1、为什么当线程数达到核心线程数时,将新提交的任务放入阻塞队列而不是直接根据最大线程数创建新的线程
这是因为设计者考虑尽量使用核心线程数处理提交的任务,不需要创建太多的线程,因为创建太多的线程会造成线程上下文切换
2、submit和execute的区别
使用execute提交任务没有返回值,submit有返回值,同时如果线程执行期间出现了异常,那么使用execute时,异常会打印在当前线程,而使用submit时,会在主线程调用future.get方法时报错。
在使用submit提交任务且需要返回值时,可以使用Callable提交任务,也可以使用Runable+返回值的方式
3、线程池中的线程抛出异常怎么办
线程池会重新创建一个线程
4、线程池的线程存活时间参数是如何其作用的
如果不考虑线程停止的情况,那么线程池中的线程如果执行完一个任务后,会重新从阻塞队列中获取任务,如果当前线程数是最小核心线程数,那么和存活时间没关系,直接使用take方法阻塞获取,如果没有任务就一直阻塞,如果当前线程数已经超过了核心线程数,那么就会调用poll方法,并传入存活时间,如果在存活时间内没有获取到任务,则会返回null,此时线程池将会把该线程停止。
5、shutdown、shutdownNow、boolean awaitTermination(timeOut, unit)
shutdown会给线程池发送信号停止线程池,但是线程池会将已经提交的任务处理完毕再关闭线程池;而shutdownNow是直接关闭线程池,但是仍会处理完正在处理的任务,但是阻塞队列中的任务不会再处理。
实现原理:调用Shutdown后会将线程池状态变更为SHUTDOWN,那么线程执行完自己的任务后,仍然会从阻塞队列中获取任务,如果获取成功,则继续处理,如果获取不到,说明没有需要处理的任务,那么则将线程数减一,这里的减一则使用了线程安全的做法,保证不会多减;如果调用了shutdownNow,则线程池的状态变为STOP,线程处理完任务后,如果线程池是STOP状态,那么就不再从阻塞队列中获取任务,直接将线程减一。
实际上这两个方法中是否会停止当前正在处理的任务,这是不一定的,因为其循环调用了线程池中线程的interrupt方法,但是线程是否能被中止,还要看线程具体的行为。
boolean awaitTermination(timeOut, unit)这个方法可以在等待一段时间后,返回线程池中的线程是否全部关闭。
以上三个方法联合起来就可以做一个简单的优雅停机,例如使用shutdown关闭线程池,然后调用awaitTermination停顿一段时间,如果线程已全部关闭,则停止主线程,如果还有线程没有关闭,则可以调用shutdownNow关闭。
五、Lock
详细解读可以看下之前的文章:
至于为什么要使用Lock,是因为synchrozied存在一些问题:
同步块的阻塞无法中断(不能 Interruptibly)
同步块的阻塞无法控制超时(无法自动解锁)
同步块无法异步处理锁(即不能立即知道是否可以拿到锁)
同步块无法根据条件灵活的加锁解锁(即只能跟同步块范围一致)
针对synchrozied的这些问题,lock都做了相关的实现
支持中断的 API:void lockInterruptibly() throws InterruptedException;
支持超时的 API:boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
支持非阻塞获取锁的 API:boolean tryLock();
读写锁:
ReadWriteLock 管理一组锁,一个读锁,一个写锁。读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。所有读写锁的实现必须确保写操作对读操作的内存影响。每次只能有一个写线程,但是同时可以有多个线程并发地读数据。ReadWriteLock 适用于读多写少的并发情况。
Condition:
通过 Lock.newCondition() 创建。可以看做是 Lock 对象上的信号。类似于 wait/notify
LockSupport:
LockSupport 类似于 Thread 类的静态方法,专门处理(执行这个代码的)本线程的。
六、Atomic与并发工具类
Atomic使用无锁技术的底层实现原理,调用Unsafe API - CompareAndSwap,即CPU 硬件指令支持 - CAS 指令,同时使用volatile修饰Value 保证可见性。
LongAdder 对于AtomicLong 的改进思路:
AtomicInteger 和 AtomicLong 里的 value 是所有线程竞争读写的热点数据;LongAdder将单个 value 拆分成跟线程一样多的数组 Cell[];每个线程写自己的 Cell[i]++,最后对数组求和,这样既保证了原子性又保证了性能。
并发工具类可以参考之前写的文章:
CountdownLatch和CyclicBarrier对比
Future、FutureTask、CompletableFuture
Future和FutureTask是单个线程或者任务执行的结果,而CompletableFuture使用了异步回调进行处理。
七、集合与并发
1、List
List不是线程安全的,如果要保证线程安全,可以使用下面四种方式:
ArrayList 的方法都加上 synchronized -> Vector
Collections.synchronizedList,强制将 List 的操作加上同步
Arrays.asList,不允许添加删除,但是可以 set 替换元素
Collections.unmodifiableList,不允许修改内容,包括添加删除和 set
在并发包中提供了CopyOnWriteArrayList,其核心原理是写数据时,Copy一个副本,写完之后将数组是的指针指向新的数组,而在写的过程中,如果进行查询,则查询的是快照数据。
2、JDK1.7 Concurrenthashmap
在JDK1.7中,使用了segment(ReentrantLock)+volitle + 数组 + 链表来实现,使用了16个segment,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。Segment 默认为 16,也就是并发度为 16。存放元素的 HashEntry,使用volitle修饰了value和next,保证并发场景下数据可见。
put元素时,首先定位到segment,然后尝试获取自旋锁,获取到后,进行put操作,如果经过多次尝试后仍然获取不到,则改为阻塞锁获取,保证可以获取到。
3、JDK1.8 ConcurrentHashmap
在JDK1.8中使用了synchrozied+cas + 数组 + 链表 + 红黑树
put元素:
(1)如果key或者value为null,则抛出空指针异常,和HashMap不同的是HashMap单线程是允许为Null;
(2)如果table为null或者table的长度为0,则初始化table,调用initTable()方法。
(3)确定元素在Hash表的索引,使用hash值的高低位做异或运算,让hash更散列,然后使用hash值和数组长度-1取模,这里转换为与运算。
(4)数组下标为null时为没有hash冲突的话,使用casTabAt()方法CAS操作将元素插入到Hash表中
(5)当f不为null时,说明发生了hash冲突,当f.hash == MOVED==-1 时,说明ConcurrentHashmap正在发生resize操作,使用helpTransfer()方法帮助正在进行resize操作。
(6)以上情况都不满足的时,使用synchronized同步块上锁当前节点Node ,并判断有没有线程对数组进行了修改,如果没有则进行后续处理(查找是否有和key相同的节点,如果有则将查找到节点的val值替换为新的value值,并返回旧的value值,否则根据key,value,hash创建新Node并将其放在链表的尾部;如果Node f是TreeBin的类型,则使用红黑树的方式进行插入。然后则退出synchronized(f)锁住的代码块)
(7)执行完synchronized(f)同步代码块之后会先检查binCount,如果大于等于TREEIFY_THRESHOLD = 8则进行treeifyBin操作尝试将该链表转换为红黑树。
(8)执行了一个addCount方法,主要用于统计数量以及决定是否需要扩容
扩容原理:
(1)首先扩容过程的中,节点首先移动到过渡表 nextTable ,所有节点移动完毕时替换散列表 table ;
(2)移动时先将散列表定长等分,然后逆序依次领取任务扩容,设置 sizeCtl 标记正在扩容;
(3)移动完成一个哈希桶或者遇到空桶时,将其标记为 ForwardingNode 节点,并指向 nextTable ;
(4)后有其他线程在操作哈希表时,遇到 ForwardingNode 节点,则先帮助扩容(继续领取分段任务),扩容完成后再继续之前的操作
size方法:
size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加 即可。