文章目录
进程与线程的区别
进程:要运行程序时就要把指令加载到CPU,数据加载到内存,当程序被运行,就是开启了一个进程,一个进程包含多个线程,是CPU分配的最小单元,进程通信需要通过网络
线程:线程是进程的最小调度单元,将指令交给CPU来执行
并行与并发
并行:多个线程同时执行
并发:多个线程交替执行
同步和异步
同步:需要等待结果返回,才能继续运行
异步:不需要等待结果返回,就能继续运行
创建线程的三种方式
Thread thread = new Thread();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println();
}
};
new Thread(runnable);
Callable<Integer> integerCallable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return null;
}
};
new Thread(new FutureTask<Integer>(integerCallable));
- 创建thread对象
- 实现runnble接口
- 实现callable接口,这个是有返回值的
线程上下文切换
因为一些原因导致cpu不执行当前线程,而去执行另一个线程比如:
- 线程的cpu时间片用完
- 有更高优先级的线程需要运行
- 线程自己调用了sleep,yield,wait等方法
sleep方法
- 调用sleep方法会让线程从Running进入Timed Waiting状态
- 其他线程可以使用interrupt方法打断正在睡眠的线程,这时sleep方法会抛出InterruptException
- 睡眠结束未必立刻运行
yield方法
- 调用yield会让当前线程从Running进入Runnable状态,就是让出cpu时间片,下次分配有可能还是这个线程执行
- 具体实现依赖于操作系统的任务调度器
join方法
- 谁调用这个线程的join方法,就要等这个线程执行完毕才能继续接着执行
- 实现:
调用join方法时,如果是无参的join,内部就是死循环的wait等待。如果有参就会等待参数的时间退出,会记录当前时间和等待时间,如果虚假唤醒会继续等待相应时间才会退出
interrupt方法
- 用来打断阻塞或者正在运行的线程
- 如果打断正在sleep,wait,join等方法时,这几个方法会抛出异常,把被打断的状态改为false
- 打断正在运行的线程,打断状态就是true,被打断以后线程不会停止运行
- 被打断的线程知道自己被打断后可以运行完后边的任务在结束。这种情况称为两阶段终止模式
park方法
- park方式是LockSupport的方法,用来让线程睡眠
- 通过interrupt方法可以打断正在park的线程
- 如果打断状态是true,park方法就无法让线程再次睡眠
- park,unpark和wait,notify区别是park以线程为单位来阻塞和休眠,notify是随机唤醒线程,不精准
- park和unpark可以先unpark,而wait和notify不能先notify
- 每个线程都有一个自己的Parker对象,由三部分组成,counter,cond,mutex。线程调用park方法时,检查conunter,如果为0就阻塞,默认为0,然后进入mutex的cond条件中阻塞,设置counter为0,调用unpark时会把counter设置为1,唤醒cond中的线程,设置counter为0。如果先调用unpark方法就会把counter设置为1,在调用park判断counter为1,就不阻塞,继续运行。
synchronized
synchronized是由Monitor实现的
线程进入被synchronized修饰的代码块时,就会把Mark Word中的信息存成monitor的地址,然后monitor中的owner指向这个线程,别的线程进来以后通过这个对象的Mark Word看看这个monitor的Owner有没有指向别的线程,如果有它就进入到EntryList的阻塞队列中等到别的线程执行完毕。拿到锁的线程执行完毕就会重置Mark Word,唤醒阻塞队列
- 轻量级锁:
开始执行synchronized修饰的方法时,首先在栈帧中创建一个所记录的对象,内部是一个对象指针和用来存储Mark Word,然后会把对象指针指向锁定的对象,然后把Mark Word与锁记录地址和状态替换,如果cas替换成功,就是加锁成功。如果失败分别有两种情况,一是其他线程持有了锁,这是就标识有竞争,锁膨胀。二是自己重入了,那就在添加一个锁记录对象,表示计数,然后cas交换肯定是失败的,然后就在地址哪里存null。解锁时发现地址为null,标识重入了,就直接删除这条锁记录,在退出发现地址有记录,就把Mark Word与对象头里存储的地址替换。如果失败,表示进行了锁膨胀或者升级为了重量级锁,进入重量级锁解锁的流程。 - 锁膨胀:
如果一条线程加轻量级锁时发现已经被别人占用了,就去申请一个Monitor锁,把Mark Word中保存的所记录地址修改为Monitor,然后自己进入EntryList,Owner就指向当前拿锁的线程 - 自旋优化:
重量级锁竞争时,线程会进行自旋拿锁,如果拿锁成功就避免阻塞,也就是非公平性。自旋时自适应的,如果刚刚自旋操作成功过,就多自选几次,反之就少自旋几次。 - 偏向锁:
偏向锁默认延迟生效,第一次会通过CAS将线程ID设置到对象的Mark Word中,之后发现是自己就表示没有竞争,就偏向这个线程。 - 撤销偏向锁:
- 对象调用hashCode方法后,这个对象就不可偏向,因为存hashCode要31位,线程ID要54位,存不下,所以就不可偏向,直接加轻量级锁。
- 当其他线程也来获取偏向锁对象时会升级为轻量级锁
- 调用wait,notify方法
- 批量重偏向:
当撤销偏向锁超过20次后,jvm会重新把这个类的所有对象偏向至加锁线程 - 批量撤销:
当偏向锁撤销超过40次后,jvm会将这个类的所有对象变成不可偏向状态 - 锁消除:
编译器对无法逃逸的局部变量加的锁会进行优化,因为不可能产生竞争,所以会优化掉这个锁
java对象内存布局
一个java对象在内存里的布局有这几块组成
- 对象头:包含Mark Word和类型指针
- 实例数据
- 对其填充(不满8倍数的字节补够8倍数位字节)
wait,notify
- wait方法会让对象进入monitor的waitSet中等待
- notify方法会让一个随机对象从monitor的waitSet中加入到EntityList中
- 所以这两个方法都必须在同步代码块中才可以执行
wait和sleep的区别
- sleep是Thread的方法,wait是Object的方法
- wait需要强制跟synchronized配合使用,sleep不需要
- sleep睡眠不释放锁,wait释放锁
同步模式之保护性暂停
用在一个线程等待另一个线程的执行结果
有一个结果需要从一个线程传递到另一个线程,让他们关联同一个保护对象
优点就是一个线程可以执行完以后随时唤醒另一个线程,join是必须要执行完才会通知另一个线程
生产者消费者模式
- 消费队列可以平衡生产和消费的爱内存资源
- 生产者只负责生产数据,不关心如果消费,消费者只关心怎么消费
- 队列有容量限制,满了就不会再加入,空了不消费
- JDK中的阻塞队列就是这种模式
死锁
两个线程抢占两个锁就有可能发生死锁
检测死锁可以使用jconsole工具,或者使用jps定位进程id,再用jstock定位死锁
活锁
两个线程同时修改一个变量使其都达不到退出的条件就会一直运行称之为活锁
ReentranLock
与synchronized区别是:
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量
条件变量Condition
Condition类似于wait的WaitSet,调用await方法时必须获得锁,然后会进入conditionObject中等待,并且释放锁,可以通过signal打断或者超时继续竞争锁。
可见性
在JMM内存模型中,线程之前共享一个主内存,每个线程有一个独立的工作内存,操作数据时要把主内存的数据加载到工作内存中,那么在工作内存中的修改就要让其他线程看见,这就是可见性。
- synchronized:可以保证可见性,因为在同步代码块操作完以后第二个线程才能进入代码块,所以是可以保证可见性的
- volatile:通过这个关键字修饰的变量,每个线程加载到工作内存后会进行嗅探总线,会监听有没有别人修改这个变量,如果别人修改了这个变量那么线程就会将工作内存的这个变量置为无效,会再次从主内存中加载一次
原子性
原子性就是要在修改时和以前的状态一样,这就是原子性
- synchronized:还是第一个线程操作完才可以进入代码块,所以保证不会被别人所修改
有序性
JVM会在不改变单线程执行结果的前提下进行一些指令重排序的优化,所以会导致多线程下一些情况的发生,所以要禁止某些重要的代码不进行排序,这就是有序性
- volatile:被修饰的变量在读写之前会加上内存屏障,防止在它前边的指令和后边的指令被重排序
CAS
CAS,比较并交换,是一种乐观实现,可以极大的提高并发量,是CPU层面实现的指令
ABA:可以加版本号来解决ABA问题
原子类
- AtomicInteger,AtomicBoolean,AtomicLong是原子整数
- AtomicReference,AtomicMarkableReference,AtomicStampedReference原子引用
- AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray原子数组
- AtomicReferenceFieldUpdate,AtomicIntegerFieldUpdate,AtomicLongFieldUpdate字段更新器
- LongAdder,LongAccumulator原子累加器
线程池
线程池是用来复用线程,避免频繁的创建销毁线程,最好是一个线程池做一个事情,防止饥饿现象
创建多少线程合适:
- CPU密集型运算,通常采用cpu核数加1能够实现最优的CPU利用率,加1保证操作系统出现故障导致暂停时他能顶上去。
- IO密集型运算,CPU不是一直处于繁忙状态,执行IO操作时cpu就会闲下来,经验公式:线程数 = 核数 * 期望CPU利用率 * 总时间(CPU计算时间+等待时间) / CPU计算时间
定时线程池
ScheduledExecutorService定时执行线程池,最新时间类LocalDateTime
Fork/Join
1.7加入的新的线程池实现,体现的是一种分治的思想,适用于CPU密集型运算,在分治的基础上加上了多线程,提高效率
JUC
AQS
阻塞式锁和相关同步器工具的框架
- state属性标识资源的状态
- 提供了FIFO队列,类似于Monitor的EntiyList
- 条件变量实现等待唤醒机制,类似于Monitor的WaitSet
ReentrantLock
- 非公平锁实现
- 加锁成功流程:首先通过CAS方法修改state,修改成功以后把自己修改为OwnerThread
- 加锁失败流程:修改state失败后,cas再次尝试加锁,构造一个Node队列,首次创建会创建两个Node,第一个Node用来占位,不关联线程,然后这个Node指向当前线程的Node,在此之间判断是否是第二个,如果是会再次尝试cas加锁,在没抢到就会把自己前边的Node节点的waitStatus改为-1,-1表示有责任唤醒下一个节点,然后循环一次,会在cas拿一次锁,还没拿到,那么久会进入队列阻塞。会进行4次拿锁,如果都失败则会进入阻塞队列
- 解锁竞争成功流程:上个线程释放锁会设置Owner为null,state等于0,判断队列是否为0并且头结点waitStatus不为0,则唤醒第一个节点,如果拿锁成功就把自己的节点设置为头结点,然后把线程设置为null
- 解锁竞争失败流程:唤醒后竞争失败会再次进入park
- 可重入原理
- 第一次加锁就会把状态改为1,如果第二次进入就判断状态是不是0,如果不是判断当前Owner是不是他自己,如果是就加1,表示重入。释放时state减1,为0时表示释放锁
- 可打断原理
- 不可被打断:被打断后只会把状态设置为true
- 可打断:被打断后抛出异常
- 公平锁实现
- 竞争锁时判断,如果队列中有两个元素,那么直接进入队列等待,不自旋
- 条件变量实现原理
- 调用await会新建一个Node状态为-2的入队列,-2为等待状态,然后通过fullyRelease流程,释放锁,然后自己调用park方法进入阻塞
- 调用signal会找到第一个Node然后唤醒这个线程,加入AQS队列尾部
ReentrantReadWriteLock
读写锁是允许读读共享,读写或者写写是阻塞的,其中读写用的是用一个Sync同步器,因此等待队列和state也是同一个,state的低16位标识写锁,高16位标识读锁
- 写加锁流程:首先判断为不为0,如果为0表示没人加锁,自己加锁。如果不为0,判断是自己重入,那么就重入一次。
- 读加锁流程:判断是否加了写锁,如果加了判断是不是自己加的,如果是自己加了写锁再加读锁可以成功,如果还不是就会自旋几次进入阻塞队列
- 写解锁流程:把state减1,如果不为0表示重入减一次,如果为0修改状态解锁,唤醒队列线程
Semaphore
信号量,用来限制能同时访问共享资源的线程上线
- 加锁流程:把初始的state进行CAS操作把总数减1,如果加锁失败就会创建队列进队列
- 解锁流程:通过CAS把state加1然后判断需不需要唤醒队列中的线程
CountdownLatch
用来进行线程同步协作,等待倒计时结束,类似于减法计数器
CyclicBarrier
循环栅栏,可以重置次数,类似于加法计数器
线程安全的集合
- 遗留下来的早期安全集合:HashTable
- 通过Collections修饰的synchronizedMap等
- JUC包下的集合类
- Blocking:大部分基于锁,并提供阻塞方法
- CopyOnWrite:修改开销相对较重
- Concurrent :内部操作很多使用cas,较高吞吐量, 弱一致性比如读取和size等
HashMap7并发死循环
HashMap1.7在两个线程同时扩容时可能会出现死循环,因为头插法所以1.8之后改为了尾插法
ConncurrentHashMap1.8
- 懒惰初始化,调用构造器时只是计算了table的大小,第一次使用时才会真正创建
- get流程:先判断为不为空或者长度小不小于0,如果成立直接返回null,然后判断头结点是不是要查找的key,如果是则返回value,如果还不是就判断hash值是不是负数,如果是负数就表示再扩容或者是tree,调用find方法寻找,如果还没有就遍历链表
- put流程:判断是否初始化,如果没初始化先初始化,判断链表头是否有数据,如果没有则CAS设置,如果在扩容,那么帮助一起扩容。如果hash冲突,会对这个链表的头节点加synchronized,判断是否为正数,正数就是链表,遍历链表,如果key相同就覆盖,找不到就尾插追加节点,如果负数判断是不是红黑树,如果是就通过putTreeVal方法添加。最后判断链表长度,如果大于等于8,则先看看数组长度有没有大于64,如果大于64则转红黑树,没有则扩容。
- initTable流程:判断是否被创建,没有就先CAS把sizeCtl设置为-1表示在初始化table,设置初始化大小,创建完成后修改sizeCtl
- size流程:size计算实际发生在put,remove改变集合元素的操作之中
- transfer扩容流程:先判断新的数组是否初始化,然后以链表为单位开始转移,如果链表头是null,就替换成ForwardingNode表示已经处理完毕,如果是-1就表示正在扩容,就开启下次循环,有元素就加synchronized开始移元素
ConcurrentHashMap1.7
内部维护了一个segment数组,每个segmet对应一把锁,优点多个线程访问segment没有冲突,但缺点是默认大小16,初始化后就不能改变了,并且不是懒惰初始化,每个segment对应一个小hash表