锁
锁分为
- 类锁
- 对象锁
-
显示锁
写在函数上的锁,不用去设置锁的谁,会自动去寻找一把锁,并且如果是static修饰的话,静态上的synchronized锁,默认持有的是这个类GpsEngine.class==类锁
如果将synchronized锁写到函数内,则需要显示设置锁定的谁
这个锁对象是str
没有静态,这里持有的是对象锁
synchronized也叫隐士锁或者内置锁,会在进去的时候锁住,出来的时候解锁,jdk内置的,我们也没办法修改
可重入锁 就是在递归的时候可以反复的拿锁
如果没有锁包裹着,那么会报错
sleep不会让出锁,不会释放锁的
ThreadLocal
ThreadLocal使用不当会有内存泄漏的风险 他产生的原因如下图
虽然ThreadLocal置为null了,但是因为线程持有的那个ThreadLocalMap还拿着Entry,在这个entry中,虽然key为null,但是value没有被释放,他的释放必须在它的根链Thread释放后才能进行释放。所以这里会有内存泄漏。
- 所以要避免这个内存泄漏的话,在用完之后及时使用threadLocal.remove。(在set get方法中虽然有调用当key为null然后清理的方法,但是他不及时)
- 如果此处entry不是弱引用的话,那就必然会泄露了,而是弱引用的话,则会一定程度上减少泄露的大小。
ThreadLocal线程不安全的问题
主要原因是静态量是唯一的(如果ThreadLocal内的value是静态的)。去掉静态就好了
CAS(Compare And Swap)
什么是原子操作,如何实现原子操作?
原子:不可再分的
利用syn包裹的东西,也算原子操作。因为当你做的时候,其他线程没办法插入,你只能完整的做完,别的才能插手。
所以,用锁是可以实现原子操作的。
因为syn来实现原子操作,是比较重的,所以现代cpu提供了compare and swap(CAS)
悲观锁和乐观锁
syn
- 悲观锁
- 同一时期只能一个去改动
CAS
- 乐观锁
- 同一时期可以多个线程进入,但是最后的原子操作是会做比较的
悲观锁会造成阻塞,一旦被阻塞,就会发生上下文切换,而一次上下文切换会牵扯很多资源。一次上下文切换所花费的时间周期,大概在5000-20000个时钟周期,也就是差不多3-5ms。而现代cpu执行一个cas指令,大概在0.6ns。虽然cas会造成多次循环,就算时间翻个几倍,也比3-5ms要好很多。而且一次拿锁的过程,要发生两次上下文切换,所以这个时间还要乘以2.而Cas只是会造成多次重试,时间花销小很多
所以现在并发编程正逐渐向cas或者无锁化进行偏移
在高度竞争,特意设计的情况下,有些情况加锁是好于cas的。但是在正常的生产环境下,一般cas都会好于加锁
自旋: 可以理解为死循环,循环尝试
CAS的不足
- 经典的ABA问题,假如小明放了一杯水在桌子上然后出去了,小刚过来拿起来喝了一口,发现不是自己的被子,就去水龙头接满放回去了,小明回来发现被子还是满的,没人喝我的水,很好,就自己喝了。
但是如果不关心中途是否被改动过,这样也就无所谓。- ABA问题可以通过添加一个版本戳来解决,只要动过这个量就修改一次,就能知道是否中途有过修改。如JDK提供的AtomicMarkableReference(关心这个变量有没有动过)与AtomicStampedReference(关心有没有动过,还关心它被动过几次,这就是这俩解决版本戳问题的区别)
- 开销问题 如自旋长期不成功
- 如果感觉开销特别大,干脆就改用加锁
- 只能保证一个共享变量的原子操作 例如不能在一个代码块中同时更改a c 甲等多个变量。
- 但是可以通过AtomicReference把多个变量组合成一个对象里,然后一次性改这个对象就可以了。
原子操作类的使用
线程池
阻塞 队列
队列 : 先进先出的一种数据结构
阻塞: 两种 当队列满了没办法放是一种阻塞,当我们想从队列拿东西,却不能拿的情况,也是一种阻塞。
阻塞队列常用于 生产者消费者模式问题
线程池之所以能复用,也有阻塞队列的原因,当队列里没有任务了,线程去调用取,就会阻塞,也就是相当于一只在run的状态,所以也就不会被销毁了。
线程池参数含义
- corePoolSize 核心线程数
- maximumPoolSize 当前线程池所能使用的最大线程数
- keepAliveTime 空闲线程的存活时间,超过就会销毁
- TimeUnit 时间的单位
- BlockingQueue
阻塞队列,将任务放到队列中 - ThreadFactory 可以适当的在线程创建的时候做一些微小的调整
- RejectedExecutionHandler 拒绝策略,对超出该线程池能力的任务进行拒绝处理
- 当任务超过核心线程数后,任务会进入到阻塞队列,当阻塞队列也满了之后,才会新起线程进行操作,但是不能超过最大线程数。如果超过了最大线程数,那么拒绝策略就开始工作了
系统提供的四个线程池拒绝策略
- DiscardOldestPolicy 抛弃最早的一个
- AbortPolicy 抛出异常
- CallerRunsPolicy 谁在往里面放任务,谁自己去执行这个任务
- DiscardPolicy 把最新提交的任务扔掉
线程池提交任务
- execute() 不关心返回值,无返回值
- sumbit() 有返回值
线程池中断
- shutdown 把所有当前没有执行任务的线程进行中断
- shutdownNow 不管当前线程有没有在执行,都会尝试着把它进行一个中断(不一定马上中断,参考线程中断,因为现在的线程中断,不是之前那样直接kill了,而是发出一个inter啥的信号,看自己有没有好好的处理了)
合理配置线程池
- 一定要区分任务的特性
cpu密集型 cpu在不停的计算,配置时不要超过机器的cpu核心数(有方法可以获取),最多顶多加一个1(为什么加1,因为可能会有虚拟内存,然后产生页缺失情况,为了防止cpu空闲出来浪费,所以加1)
IO密集型 (读写磁盘或者网络) 网络通讯、读写磁盘等。线程数一般来说推荐的经验值是cpu核心数2.因为读写磁盘或者网络效率远远低于读写内存。*
-
混合型 既有cpu密集型,又有io密集型
- 如果两种类型耗时差不多,考虑拆成两个线程池
- 如果差距大,以占比大的为准
-
任务队列,在绝大多数配置成有界的,因为无界的完全有可能会撑爆我们的机器。
现在cpu,io操作基本上不用cpu,现在计算机基本提供DMA机制,但是说完全不用,这种话太绝对了。 0拷贝的出现是因为为了防止内核空间与用户空间的两次拷贝耗性能,直接在内核空间开辟一个供该程序使用的空间,减少了两次拷贝。 内核空间和用户空间的划分,是为了防止用户空间崩溃导致内核空间异常,也为了防止用户空间修改内核空间。
并发知识补全--线程的状态/线程的生命周期
- yield方法,让出当前线程从运行中变为就绪状态,也就是让出cpu执行权
- join方法 让当前线程执行完
sleep、yield方法调用,如果当前线程有锁,那么是不会释放的。wait方法调用,如果有锁,则是会释放锁的。在被唤醒的时候,是会重新竞争这个锁。
sleep是可以中断线程的。所以写的时候要捕获这个中断异常的 - 如果让a b c三个线程按顺序执行
- 可以使用线程的join方法,例如在c的run方法里调用b.join,在b的run方法里调用a.join
- 线程有优先级的设置,高优先级占用cpu的时间可能会更长,但是这是由操作系统实现的,并不一定
- 守护线程,有方法可以设置为守护线程,当一个进程中,所有的非守护线程,用户线程都结束后,这个进程就停止了,守护线程也跟着停止了。并且守护线程的try finally中finally方法不一定调用,所以守护线程中不能通过finally方法块进行一些资源的释放清除操作
线程的中断
stop被废弃了,因为他带有强制性,可能导致当前线程操作没有结束就强行停止。
-
interrupt 我们可以调用这个方法来进行线程的停止,但是在jdk中,他并不是一个真真正正来终止一个线程的,他其实是一个给线程的中断标志位,给线程打了一个招呼。不代表线程要立即停止工作,而且线程可以完全不理会这个中断请求。(所以里面有一个概念,jdk里线程是协作式的而不是抢占式,是否停止完全看线程自己做主,看自己有没有处理isInterrupt标志位)像sleep等方法,也会有处理interrupt标志位,所以尽量使用这个标志位,而不是自己定义一个成员变量来作为这个标志位进行判断
所以,我们调用sleep等方法,都要有捕获这个异常,当外界调用interrupt方法后,catch到异常,会把这个标志位重新赋值为false,所以我们在catch块中获取标志位,还是会为false的。所以这类catch中,我们还需手动调用一次interrupt方法,这样才能真正的终止线程。(java之所以这么设置,类似于stop的问题)
判断这个标志位有两个方法,一个是isInterrupted方法,另一个是一个静态的interrupted方法,他俩都可以判断当前标志位,所不同的是,静态的这个方法,会在判断完之后,将这个标志位重新赋值为false
如果是通过实现runnable接口实现的线程操作,他里面并没有interrupt这个标志位,所以可以通过thread.currentThread.isInterrupted来进行判断
所以要理解Thread Runnable实现的线程操作,他们具体抽象的不同,Thread是java提供的唯一抽象线程的实现,而Runnable相当于一个个操作操作任务不过当我们刚new出来一个thread的时候,他还并没有和线程挂钩,只是一个类,只有当我们调用start方法时,才完全挂钩,里面有start0这是一个native方法。并且,多次调用start方法是会出错的,有抛出异常,非法状态。run方法,是业务实现的方法
注意,死锁状态下是不会理会中断标志位的
问题
- 通过显示锁lock锁定后,一个线程在等待获取这个锁的时候,会进入到什么状态
会进入到等待或者等待超时的状态,因为阻塞状态有且只有当等待进入synchronized的时候,才能进入阻塞态。而lock相当于LockSupport.parkNanons
另外可以理解为,阻塞是被动进入,而等待是主动进入
运行态包括运行中和就绪,这俩二合为一,是在java里这么规定的,而在操作系统的分类中,是把这两种状态分为两种不同的状态。
死锁
产生条件
- 多个操作者(m>=2)争夺多个资源(n>=2),并且n<=m
- 争夺资源的顺序不对
- 拿到资源不放手
学术化的表达
- 互斥条件
- 请求保持
- 不剥夺
- 环路等待
AbstractQueuedSynchronizer
AQS 是一个抽象类,他的具体实现都有
从中可以看出AQS是JDK并发里的一个基础的构建。
FutureTask在JDK1.5 1.6以及之前还是基于AQS实现的,只不过后来考虑到性能,就改了。不过思想还是没变的。
AQS是用来构建同步组件的。
AQS使用了模版方法设计模式。所以,要实现自己的同步工具类的话,要用子类具体实现其某些方法。其最重要的几个东西有 成员量state,方法 tryAcquire()、tryAcquireShared(),tryRelease()、队列、等
-
下图是一个不可重入的锁,下面会有完善,就是通过判断线程
AQS的基本思想CLH队列锁(CLH队列锁即Craig, Landin, and Hagersten (CLH) locks)
CLH队列锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程仅仅在本地变量上自旋,它不断轮询前驱的状态,假设发现前驱释放了锁就结束自旋。
每一个等待拿锁的线程,打包成一个节点,挂到队列上,然后每个线程会不断的检测前一个线程是否释放锁,释放了,当前线程就能拿到锁。
Java中的AQS是CLH队列锁的一种变体实现
在AQS中,等待队列有多个,而在Syn里,等待队列只有一个。但是,他们的基本思想,都是脱胎于CLH队列锁。
了解ReentrantLock的实现
- 的公平锁与非公平过的唯一区别,也就是在实现tryAcquire的时候,判断队列是否存在。公平的就插到最后,不公平的就不管队列。
-
锁的可重入 递归可重进,还有如 syn a() syn b(),然后a方法里调用里b,如果不是可重入的话,那么就会死锁。所以,如果想实现可重入锁,更改的地方也不多,如上图的例子,只需要在tryAcquire方法里多一层判断,判断是否当前线程拿到了锁就可以了。
- 加1减1操作,是为了释放锁,因为递归的时候,你相当于进去多次,所以释放也需要多次。
深入了解并发编程,除了AQS,还有一个很重要的东西---JMM(JAVA Memory Model)
因为CPU读取内存与执行指令时间相差很多,所以引入了高速缓存策略。
从中可以看出,cpu从内存,L1 ,L2, L3中读取的时间,分别为,59.4ns,1.2,5.5,15.9ns
工作内存和主内存,是俩抽象的概念,并不是说实体,比如工作内存,可能就报错了寄存器,高速缓存,RAM主内存,但是大小分配不同,可能高速缓存占用了百分之99,而主内存,可能RAM祝内存占了百分之99,其他俩才一点。
当进行一个a当累加,此时,祝内存的a要进入到各个线程中的工作内存中进行。有点类似ThreadLocal的概念。或JVM栈和堆的概念。
从中就能看出两个问题
- 可见行 A B两个线程,是没办法得知它所操作的count变量的值的。
- volatile关键字(还有抑制重排序的功能(重排序涉及到现代CPU的流水线和重排序功能概念)),可以实现可见行,可以强迫两点,1.如果我们要用这个变量,强迫他从主内存中读取一次,2.每当我修改了这个变量,强迫他马上刷新到主内存。所以他的常用场景在一写多读的情况下
- 那为什么加了这个关键词,还是没办法解决并发累加问题呢?因为,它只是强迫你每次运算之前读到工作内存中,并且算完之后,强制同步到主内存中,但是问题在于,这个计算过程,并不是一次就能搞定到,他不是一个原子性操作,他肯定还有会上下文切换,轮转,所以还得需要加锁
只加锁不加可见行操作,可否正确???
- 可以,因为volatile操作相当于jdk提供的最最轻量级的同步机制,而syn关键字比volatile的强度要大,syn同时保证了可见性和原子性,而volatile只保证了可见性。最多最多只能保证我对变量的修改会马上同步到内存里面,任何一个其他线程对这个变量的操作,都得强制要求去主内存中去读取最新值。
volatile 详解
volatile应用场景
- 一个线程写,多个线程读,这种完全没问题
- java领域里,写操作之间没有任何关联(如a=a+1,这个有问题,因为他和他之前的值有关联),这种情况,也是没问题的。
volatile的实现原理
Synchronized实现原理
- 在java编译为class文件的时候,虚拟机给加入的指令,将他加在了所包代码的前后位置(这是加在了代码块中的情况)。所说的拿锁,本质上就是拿到Monitor这个对象的所有权。
- 当加载方法声明上时,情况为添加到flags上,ACC_SYNCHRONIZED,如下图
不过在底层,他的本质还是monitorenter monitorexit,只不过是加在了我们看不到的运行时的看不到的位置,在字节码上没有体现。
JVM就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象
synchronized使用的锁是存放在Java对象头里面
SYN加锁,这个锁放在什么位置呢?----他放在了java的对象头里
假如,加锁的代码块,指令一共也就30条,cpu执行完也就需要多少纳秒。但是由于加了锁,导致上下文切换一次就非常耗时,于是,就引入了轻量级锁,偏向锁等等。。
对象头组成
具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息
但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式
锁的状态
锁一共有四种状态,级别从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。
-
轻量级锁:使用自旋锁的方式,通过CAS操作来进行加锁和解锁,而不是很重的,将线程挂起阻塞。(但是大多数情况下,他都是在空转,消耗cpu,所以我们也不能一直这么干耗着一只自旋。于是在自旋锁的基础上,引入了一种适应性的自旋锁---也就是控制自旋的次数)在早起版本jdk1.5 还是1.6,定义为10次,在1.6引入适应性自旋锁,不再是10了,而是由虚拟机自行的判定,动态进行调整。一般现在虚拟机设置的时间,就是线程上下文切换所用的时间(这是很显然的,就是为了避免重量级锁产生的上下文切换,如果超过上下文切换时间,那就没必要了),一旦超过这个时间,就不再自旋了,而是膨胀为重量级锁。
- 在轻量级锁里面,每当一个线程去尝试拿锁时,都会通过cas操作,去修改对象头里的数据。替换为轻量级锁的数据。
-
偏向锁:现代虚拟机统计发现,在大多数情况下,一个锁总是由同一个线程多次获得的。所以,这情况下,如果连CAS操作都懒得去做了。拿锁的时候,就测试下当前拿锁的是不是自己,是自己就直接来用,这样就变成了偏向锁,为了让自己拿锁的代价更低。不过第一次还是得用CAS操作进行一次对象头相关数据的替换。之后就不需要CAS操作了。
-
不过一旦发生线程竞争的情况,锁也就得升级了,变为轻量级锁,就得撤销轻量级锁(对象头数据不同,也就是替换成轻量级锁的数据。在偏向锁的撤销里,也存在着stop the world的现象)因为撤销偏向锁的时候,线程2要去撤销线程1的偏向锁的时候,要去修改线程1的堆栈内存的。(不是说jmm里所述,不同线程不能互相访问各自的线程里内容吗----因为这是对开发者而言的,对虚拟机来说,它是可以去修改的,所以线程2会去修改线程1里面的数据,所以当线程1一直执行的时候,线程2也没法改,所以需要stop the world)
-
重量级锁: 没拿到锁的线程,都是会被挂起来的,这样就会有上下文切换。
锁膨胀后是不能退回的,不可逆
并且syn里的偏向锁,轻量级锁,重量级锁,不是我们代码实现的,他是syn底层的实现,是虚拟机里C++实现的,和我们代码无关。
这些syn的优化,从1.6开始的
不同锁的比较
JAVA主流锁分类
SYN锁是非公平锁的实现。SYN锁也是可重入锁
读写锁--读锁就是一个典型的共享锁,写锁,是排他锁,排斥其他写也排斥其他读
Synchronized做了那些优化
为了提升性能,引入了自旋锁,适应性自旋锁,偏向锁,轻量级锁,锁消除,锁粗化,逃逸分析
DCL中volatile的作用
抑制重排序的可能产生的问题。因为重排序,可能会导致new创建对象三步错乱,当还没初始化里面的数据的时候,却已经分配了引用,那么这时候调用,就可能会出现空指针。所以在DCL中需要加入valotile关键字。
延迟初始化的占位类模式单利(使用静态内部类实现的单利)
为什么饿汉式与静态内部类方式可以保证线程安全
虚拟机的本身的类加载机制,他在内部进行类加载的时候已经实现了线程安全,所以说就能实现。因为当一个类被加载的时候,完全有可能出现多个线程同时加载一个类的情况,所以虚拟机会进行一个加锁,保证任何一个时刻,只能一个线程去执行类加载机制。