Java并发-Synchronized关键字
一、多线程下的i++操作的并发问题
package passtra;
public class SynchronizedDemo implements Runnable{
private static int count=0;
@Override
public void run() {
for(int i=0;i<10000000;i++){
count++;
}
}
public static void main(String[] args) {
for(int i=0;i<10;i++){
new Thread(new SynchronizedDemo()).start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.err.println("result:"+count);
}
}
开启了10个线程,每个线程都累加了10000000次,如果结果正确的话总数应该是10*10000000=1000000000.可是运行多次结果都不是这个数,而且每次运行结果都不一样。
线程安全问题主要来源于JMM的设计,主要集中在主内存和线程的工作内存而导致的内存可见性问题,以及重排序导致的问题。
线程运行时拥有自己的栈空间,会在自己的栈空间运行,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么多线程就不能发挥优势,不能带来巨大的价值。
那么共享数据的线程安全问题怎么处理?就是让每个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新版本数据。那么,在Java中synchronized就具有使每个线程依次排队操作共享变量的功能,很显然,这种同步机制相率很低,但synchronized是其他并发容器实现的基础。
package passtra;
public class SynchronizedDemo implements Runnable{
private static int count=0;
@Override
public void run() {
synchronized(SynchronizedDemo.class){
for(int i=0;i<10000000;i++){
count++;
}
}
}
public static void main(String[] args) {
for(int i=0;i<10;i++){
new Thread(new SynchronizedDemo()).start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.err.println("result:"+count);
}
}
二、synchronized最主要的使用方式
在Java代码中使用synchronized可使用在代码块和方法中,根据synchronized使用位置可以有这些使用场景:
synchronized可以用在方法上也可以用在代码块上。其中方法还是实例方法和静态方法分别锁的的是该类的实例对象和该类的对象。修饰实例方法,作用于当前的实例对象加锁,进入代码同步代码前要获得当前对象的实例的锁。修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前对象的锁。
也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static表明这是该类的一个静态资源,不管new了多少个对象,只有一份,所有对该类的所有对象都加了锁)。所以如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B需要调用这个实例对象的所属类的静态synchronized方法,是允许的,不会发生互斥现象,因为访问静态synchronized方法占用的锁时当前的类,而访问非静态synchronized方法占用的是当前实例对象锁
修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码快库前要获得给定对象的锁。
和synchronized方法一样,synchronized(this)代码块也是锁定当前对象的。synchronized关键字加到static关键字和synchronized(class)代码块上都是给Class类上锁。另外需要注意的是:尽量不要用synchronized(String a)因为JVM中,字符串常量池具有缓冲功能
三、写一个synchronized实现的双重校验实现的单例模式
package passtra;
public class Singlethon{
private volatile static Singlethon uniqueInstance;
private Singlethon(){
}
public static Singlethon getUniqueInstance(){
//先判断对象是否实例化过,没有实例化进入加锁代码
if(uniqueInstance==null){
//类对象加锁
synchronized(Singlethon.class){
if(uniqueInstance ==null){
uniqueInstance=new Singlethon();
}
}
}
return uniqueInstance;
}
}
uniqueInstance采用volatile关键字修饰也是很有必要的,可以禁止JVM的指令重排,保证在多线程环境下也能正常运行,uniqueInstance=new Singletion();这段代码其实是分三步执行的:
1、为uniqueInstance分配内存空间
2、初始化uniqueInstance
3、将uniqueInstance指向分配的内存地址
但由于JVM具有指令重排的特性,执行顺序有可能变成1->3->2.指令重排在单线程下不会出现问题,但在多线程下会导致一个线程互殴的还没有初始化的实例。例如,线程A执行了1和3,此时线程B调用getuniqueInstance()后发现uniqueInstance不为空,因此返回uniqueInstance,但此时uniqueInstance还未被初始化。
四、synchronized在JDK1.6之后有哪些底层优化
在早期的版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层操作系统的Mntex Lock来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
在JDK1.6之后从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了。
JDK1.6对锁的实现引入了大量的优化,如:自旋锁、适应性自旋锁、所消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
五、CAS操作
什么是CAS
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然就不会阻塞其他线程的操作,因此线程就不会出现阻塞停顿的状态.如果出现了冲突怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
CAS操作过程
CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V:内存地址存放的实际值;O:预期的值(旧值);N:更新的值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值,就可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当对个线程使用CAS操作一个变量时,只有一个线程会成功,并更新成功,其余线程会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。
CAS应用场景
在J.U.C包中利用CAS实现类有很多,在Lock实现中会由CAS改变state变量,在atomic中的实现类中几乎都是用CAS实现,JDK1.8中的concurrentHashMap等等。
CAS问题
1、ABA问题
因为CAS会检查旧值有没有变化,这里存在一个问题,比如一个旧值A变成B然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化,解决方案可以演戏数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->C就变成了1A->2B->3C。JDK1.5后atmoic包中提供了AtomicStampedReference来解决BA问题。
2、自旋时间过长
使用CAS非阻塞同步,会自旋进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。JDK1.6后加入了适应性自旋:如果某个锁自旋很少成功获得,那么下一次就会减少自旋。
3、只能保证一个共享变量的原子操作
当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量金性操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,然后将这个对象做CAS操作。atomic中提供了AtomicReference来保证引用对象之间的原子性。
六、偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁的获取
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS加锁h和解锁,只需要简单地测试一下对象头的Mark word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得锁。如果测试失败,则需要再测试一下Mark word中偏向锁标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放的机制,所以当其他线程尝试竞争偏向锁时持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
线程1展示了偏向锁获取的过程,线程2 展示了偏向锁撤销的过程。
如何关闭偏向锁
偏向锁在Java6和7里是默认开启的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。
如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁: -XX:UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
七、轻量级锁
加锁
线程在执行同步块之前,JVM会先在当期线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displace的 Mark Word。
然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。
如果更新成功,当前线程获得锁。
如果更新失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头。
如果成功,则表示没有竞争发生
如果失败,便是当前锁存在竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争
八、偏向锁,轻量级锁、重量级锁的比较
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,只用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不会使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较长 |
九、为什么wait、notify、notifyAll要与synchronized一起使用
wait、notify、notifyAll方法都是Object的方法,也就是说,每个类中都有这些方法。
Object.wait():释放当前对象锁,并进入到阻塞队列
Object.notify():唤醒当前对象阻塞队列里的任一线程(并不保证是哪个)
Object.notigyAll():唤醒当前阻塞队列里的所有线程
为什么这三个方法要和synchronized一起使用呢?我们要先了解到:
每一个对象都有一个与之对应的监视器
每一个监视器里面都有一个该对象的锁和一个等待队列和一个同步队列
wait()方法的语义有两个,一个是释放当前锁,另一个是进入阻塞队列,可以看到,这些操作都是与监视器相关的,当然要指定一个监视器才能完成这个操作了。
notify()方法也是一样,用来唤醒一个线程,你要去唤醒,首先你要知道它在哪,所以必须先找到该对象,也就是获取该对象的锁,当获取对象的锁之后,才能去该对象的对应的等待队列去唤醒一个线程,值得注意的是,只有执行唤醒工作的线程离开同步块,即释放锁之后,被唤醒的线程才能去竞争锁。
notifyAll()方法和notify()方法一样,只不过是唤醒等到队列中的所有线程。
因wait()而阻塞的线程是放在阻塞队列中的,因竞争失败导致的阻塞是放在同步队列中的,notify和notifyAll实质上是把阻塞队列中的线程放到同步队列中。