Java锁
Java锁
乐观锁:
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写操作。
Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
CAS:
CAS(Compare And Swap/Set)比较并交换,CAS算法的过程是这样:它包含三个参数CAS(V,E,N) ,V表示需要更新的变量(内存值),E表示预期值(旧的),N表示新值。当且仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后, CAS返回当前V的真实值。
CAS操作时抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。
ABA问题:
CAS会导致“ABA问题”。CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较和替换,那么在这个时间差类会导致数据的变化。
比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出
A,并且two进行了一下操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然时A,然后one操作成功,尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。
部分乐观锁的实现是通过版本号(version)的方式来解决ABA问题,乐观锁每次在执行数据修改的操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加不会减少。
AQS
AbstractQueuedSynchronizer(抽象队列式同步器),AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,(它用了模板方法设计模式)如常用的ReentrantLock/Semaphore/CountDownLatch
悲观锁
悲观锁就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会被block直到拿到锁。Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到,才会转换为悲观锁,如ReentrantLock
自旋锁
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待
竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋)等待有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
线程自旋时需要消耗CPU的,说白了就是让CPU在做无用功,如果一直获取不到锁,
那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行时间超过自旋等待的最大时间仍没有释放锁,就会导致其它争用锁的线程在最大时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码来说性能有大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
但是如果锁的竞争激烈,或者持有锁的线程需要更长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁再获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁。
Synchronized同步锁
synchronized它可以把任意一个非NULL的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。
Synchronized作用范围
作用于方法时,锁住的是对象的实例(this);
当作用域静态方法时,锁住的时Class实例,又因为Class的相关数据存储在永久代PermGen(jdk1.8则是metaspace),永久代时全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块,它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
ReentrantLock
ReentrantLock继承接口Lock并实现了接口中定义的方法,它是一种可重入锁,除了能完成Synchronized锁能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
ReentrantLock与synchronized(高频)
ReetrantLock通过方法lock()与unlock()来进行加锁与解锁操作,与synchronized会被JVM自动解锁机制不同,ReentrantLock加锁后需要手动进行解锁。为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作。
ReentrantLock相比synchronized的优势是可中断、公平锁、多个锁。这种情况下需要使用ReentrantLock。(现在的话synchronized也可以设置公平锁)
synchronized和ReentrantLock的区别
ReentrantLock显示的获得、释放锁,synchronized隐式获得释放锁
ReentrantLock可响应中断、可轮回,synchronized是不可以响应中断的,为处理锁的不可用性提高了更高的灵活性。
ReentrantLock是API级别的(接口),synchronized是JVM级别的
ReentrantLock可以实现公平锁(也不算区别了)
ReentrantLock通过Condition可以绑定多个条件
底层实现不一样,synchronized是同步阻塞,使用的是悲观并发策略,lock是同步非阻塞,采用的是乐观并发策略
Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置语言实现
synchronized在发生异常时,会自动释放线程占用的锁(JVM),因此不会导致死锁现象的发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。
Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。
通过Lock可以直到有没有成功获取锁,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。
通过Lock可以直到有没有成功获取锁,而synchronized却无法办到
Lock可以提高多个线程进行读操作的效率,即实现读写锁等
Semaphore信号量
Semaphore是一种基于计数的信号量。它可以设定一个阈值,基于此,多个线程竞争获取许可信号,做完自己的申请后归还,超过阈值后,线程申请许可信号将会被阻塞。Semaphore可以用来构建一些对象池,资源池之类的,比如数据库连接池
实现互斥锁(计数器为1)
我们可以创建计数为1的Semaphore,将其作为一种类似互斥锁的机制,这也叫二元信号量,表示两种互斥状态。
可重入锁(递归锁)
本文里讲的是广义上的可重入锁,而不是单指Java下的ReentrantLock。可重入锁,也叫递归锁,值指的是同一线程外层函数获得锁之后,内层递归函数仍然有获取该锁的代码,但不受影响,在Java环境下,ReentrantLock和synchronized都是可重入锁。
AtomicInteger
首先说明,此处AtomicInteger,一个提供原子操作的Integer的类,常见的还有AtomicBoolean,AtomicInteger,
AtomicLong,AtomicReference等,他们的实现原理相同,区别在于运算对象类型的不同。令人兴奋的是还可以通过AtomicReference<V>将一个对象的所有操作转化为原子操作。
我们直到,在多线程程序中,诸如++i或者i++等运算不具有原子性,是不安全的线程操作之一,通常我们会使用synchronized将该操作变成一个原子操作,但JVM为此类操作特意提供了一些同步类,使得使用更方便,且程序运行效率变得更高。通过相关资料显示,通常AtomicInteger的性能是ReentrantLock的好几倍。
锁的类型
公平锁和非公平锁
非公平锁:
JVM按随机、就近原则分配锁的机制则成为非公平锁,ReentrantLock在构造函数中提供了是否公平锁的初始化方式。默认为非公平锁。非公平锁实际执行的效率要远远超出公平锁,除非程序有特殊需要,否则最常用非公平锁的分配机制
公平锁:
公平锁指的是锁的分配机制是公平的,通常先对锁提出获取请求的线程会先被分配到锁,ReentrantLock在构造函数中提供了是否公平锁的初始化方式来定义公平锁。
共享锁和独占锁
java并发包提供的加锁模式分为独占锁和共享锁。
独占锁模式下,每次只能有一个线程能持有锁,ReentrantLock就是以独占方式实现的互斥锁,独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。
共享锁则允许多个线程同时获取锁,并发访问共享资源,如:ReadWriteLock。共享锁则是一种乐观锁 ,放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。
重量级锁(Mutex Lock)
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock锁实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
轻量级锁
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
偏向锁
大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小玉节省下来的CAS原子指令的性能消耗)。
上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
分段锁
分段锁也并非一种实际的锁,而是一种思想ConcurrentHashMap是学习分段锁的最好实践。
锁优化
减少锁持有时间
只有在有线程安全要求的程序上加锁
减小锁粒度
将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。最最典型的减小锁粒度的案例就是ConcurrentHashMap。
锁分离
最常见的锁分离就是读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能。读写分离思想可以延伸,只要操作互不影响,锁就可以分离。比如LinkedBlockingQueue从头部取出,从尾部放数据
锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。
锁消除
锁消除是在编译器级别的事情。在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作,多半是因为程序员编码不规范引起
同步锁与死锁
同步锁:
当多个线程同时访问同一个数据时,很容易出现问题。为了避免这种情况出现,我们要保证线程同步互斥,就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。Java中可以使用synchronized关键字来取得一个对象的同步锁。
死锁:
何为死锁,就是多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。
线程池原理:
线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超多了最大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。它的主要特点为:线程复用;控制最大并发数(线程数量是开始设置的);管理线程(创建和消亡)。
线程池的参数
corePoolSize:指定了线程池中的线程数量。
maximumPoolSize:指定了线程池中的最大线程数量。
keepAliveTime:当前线程池数量超过corePoolSize时,多余的空闲线程的存活时间,即多次时间内会被销毁。
unit:keepAliveTime的单位
workQueue:任务队列,被提交但尚未被执行的任务。
threadFactory:线程工厂,用于创建线程,一般用默认的即可。
handler:拒绝策略,当任务太多来不及处理,如何拒绝任务
拒绝策略4种
线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了,这时候我们就需要拒绝策略机制合理的处理这个问题。
AbortPolicy:直接抛出异常,阻止系统正常运行。
CallerRunsPolicy:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务,
显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。
DiscardOldestPolicy:丢弃最老的一个请求,也就是即将被执行的一个任务(也就是最早加入队列的任务),并尝试再次提交当前任务。
DiscardPolicy(常用,一般配合MQ处理):该策略默默地丢弃无法处理的任务,不予任何处理,如果允许任务丢失,这是最好的一种方案。
Java线程池工作过程
线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
当调用execute()方法添加一个任务时,线程池会做如下判断:
如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务。
如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列;
如果这时候队列满了,而且正在运行的线程数量小玉maximumPoolSize,那么线程池会抛出异常RejectExecutionException。
当一个线程完成任务时,它会从队列中取下一个任务来执行。
当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小。