【Java】Java并发编程中的锁(一)

锁是Java并发编程中最重要的同步机制,使用锁可以让临界区中的代码互斥执行(即多线程串行执行)。

synchronized

synchronized是Java提供的关键字,以其简单易用,成为开发者的首选。所以我们见到的大部分的并发控制都是用synchronized来实现的。

synchronized的使用形式

synchronized有两种形式,一种是修饰代码块,一种是修饰方法,如下

//方式一:修饰代码块
public void fun1(){
    synchronized(obj){
    //某些操作
    }
}
//方式二:修饰方法
public synchronized void fun2(){
    //某些操作
}

很容易产生的一个误区是,锁是加在临界区代码块上,比如上面的代码,很多初学者可能会认为锁是加在 “{ //某些操作 }”。其实理解synchronized的核心就是要理解锁是加在哪里,只要抓住这一点,实践中遇到的各种诡异的问题就能迎刃而解。

synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对像。如果程序中的synchronized明确指定了对象参数,例如synchronized(obj),那就是这个对象的reference;如果synchronized修饰的是实例方法或者类方法,则是取对应的对象实例或Class对象来作为锁对象。

上面这段话看起来也许难以理解,下面就让我们来一步一步学习理解synchronized。

不使用锁的场景

下面的例子,我们创建了一个报数的类,启动两个线程来测试,这时不使用锁。

//示例代码1:没有锁控制的场景
public class JavaLockTest {
    public static void main(String[] args)  {
        CountOff countOff = new CountOff();
        Thread t1 = new Thread(countOff, "A");
        Thread t2 = new Thread(countOff, "B");
        t1.start();
        t2.start();
    }
}

class CountOff implements Runnable {
    public  void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(Thread.currentThread().getName() + " say: " + i);
            try {
                Thread.sleep(50);//睡一小会,便于观察;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

我想大家肯定都猜到了结果,A、B两个线程的报数情况将会出现穿插,如下

//报数穿插的输出
A say: 1
B say: 1
B say: 2
A say: 2
A say: 3
B say: 3
B say: 4
A say: 4
A say: 5
B say: 5

synchronized修饰代码块

现在,我们来改造代码,在CountOff类中加入一个实例变量作为锁对象:

//示例代码2:加入一个成员变量,用作锁对象
class CountOff implements Runnable {
    private final Object lock = new Object();
    public  void run() {
        synchronized(lock) {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + " say: " + i);
                try {
                    Thread.sleep(50);//睡一小会,便于观察;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

再次运行,我们得到了想要的结果,A线程报完数后,B线程接着报数,不再穿插:

//A、B依序报数,不再穿插
A say: 1
A say: 2
A say: 3
A say: 4
A say: 5
B say: 1
B say: 2
B say: 3
B say: 4
B say: 5

我们来理解一下整个过程。临界区代码块就像是一个房间,进入这个房间可以做一些美妙的事情(比如sleep)。首先我们声明了一个Object对象lock,要用它作为锁对象(锁对象拥有房间钥匙)。A线程首先执行到synchronized(lock)代码,它获得了锁(拿到钥匙),进入房间享受,B线程只能在门外等着;等A线程享受完毕,从房间退出时,会释放锁(归还钥匙),此时B线程就拿到钥匙进入房间……

理解起来很简单是不是?不要着急~~生命在于折腾,再搞一搞看?现在我们稍微改动一下main方法:

//示例代码3:修改main方法,用两个实例来运行
public static void main(String[] args)  {
    CountOff countOff = new CountOff();
    CountOff countOff2 = new CountOff();//注意这里
    Thread t1 = new Thread(countOff, "A");
    Thread t2 = new Thread(countOff2, "B");//注意这里
    t1.start();
    t2.start();
}

看看结果:

//又变混乱了
A say: 1
B say: 1
A say: 2
B say: 2
A say: 3
B say: 3
B say: 4
A say: 4
A say: 5
B say: 5

怎么回事?这里就引出本篇文章要强调一个重点:多个线程只有在争用同一个对象上的锁时,才会形成互斥。这里请把“同一个对象”大声读三遍,谢谢~(Java如何判断两个对象是否是同一个对象?判断其地址是否相同即可)。在这个实验里,我们创建了CountOff类的两个实例来分别执行报数,锁对象countOff.lock和countOff2.lock实际上是两个不同的对象,A、B线程各持有一个,它们无法形成互斥。实践中大多数问题就出在这里。

现在我们知道把锁加在类的实例变量上是不妥当的,除非你能保证在你的系统里是以单例模式来使用该类的。—-何必费这么大劲呢?把锁对象声明为static,让它成为类变量,所有的实例都共享类变量,这样一来就符合“同一个对象”的限定,用来做锁,妥妥的~

//示例代码4:把锁对象声明为static ,这样就不用担心多实例引发的问题了
private  static Object lock = new Object();

synchronized修饰方法

接下来,我们再看看用synchronize修饰方法。这时候大家会问:锁是加在锁对象上的,当修饰方法时,这个锁对象是谁呢? 还记得文章开头部分的那句话吗?–“如果synchronized修饰的是实例方法或者类方法,则是取对应的对象实例或Class对象来作为锁对象”。我们来改造一下示例代码,以帮助大家理解。


public class JavaLockTest2 {
    public static void main(String[] args)  {
       final Action ac = new Action();
        // A线程执行sing方法,B线程执行speak方法
        Thread t1 = new Thread( new Runnable() { public void run(){ ac.sing();}}, "A");
        Thread t2 = new Thread(new Runnable() { public void run(){ ac.speak();}}, "B");
        t1.start();
        t2.start();
    }
}

class Action {
    public synchronized void sing() {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + " sing: " + i);
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    public void speak() {
        synchronized(this) {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + " speak: " + i);
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
//输出结果
A sing: 1
A sing: 2
A sing: 3
A sing: 4
A sing: 5
B speak: 1
B speak: 2
B speak: 3
B speak: 4
B speak: 5

在上面的代码中,声明了一个实例ac,A线程调用用synchronized修饰的sing( )方法,B线程调用speak( )方法,speak( )方法中有用synchronized(this)修饰的代码块,这里的this是指向调用该方法的类实例ac。
看输出结果,整整齐齐,说明发生了互斥,证实了synchronized 修饰实例方法时相当于 synchronized(this)。根据上一节中的内容,锁加在实例上,在多实例的场景下就失去了作用,这个大家要注意。

继续验证,把synchronized加在静态方法上的情形。我们修改一下Action类继续做测试:

class Action {
    //sing方法被声明为static的
    public synchronized static void sing() {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + " sing: " + i);
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    public void speak() {
    //这里的锁对象为Action.class,PS:类对象也是对象哦
        synchronized(Action.class) {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + " speak: " + i);
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
//输出结果
A sing: 1
A sing: 2
A sing: 3
A sing: 4
A sing: 5
B speak: 1
B speak: 2
B speak: 3
B speak: 4
B speak: 5

可以看到输出结果井然有序,证实了synchronized修饰静态方法时,是以类对象作为锁对象的。

至此,synchronized的基本用法介绍完啦。

如何选择使用synchronized的形式?

既然Java给出了这么多种用法,当然都有其用武之地。这里只能给一些大致的建议。
synchronized修饰方法时,其粒度太大(直接以实例或类对象做为锁对象),高并发下性能容易有影响。举个极端例子,一个类10个静态方法,各个方法间不干扰,但需要保证每个方法在同一时间只能被一个线程访问,如果10个方法都用synchronized修饰,有10个线程分别要访问不同的方法,此时都会互斥,整个处理过程消耗的时间为10个方法执行时间之总和。而事实上,由于是访问不同的方法,理想情况下,整个处理过程消耗的时间应该等于时间最长的那个方法消耗的时间。
所以比较推荐的是用静态的类变量做为锁对象(实例变量在多实例的场景下会有问题,前面已经讲过),这样粒度更小,更灵活,避免引起大范围的互斥(代价是写法复杂了点)。

//推荐使用一个长度为0的数组做为锁对象,据说这样的开销比new Object()要小
private  final static byte[] lock = new byte[0];

小结

  • synchronized关键字的用法有两种方式,修饰代码块或修饰方法;
  • synchronized修饰实例方法时,相当于在代码块中使用synchronized(this),此时是以类实例作为锁对象;
  • synchronized修饰静态方法时,相当于在代码块中使用synchronized(XXX.class),此时是以类对象作为锁对象;
  • 多个线程只有在争用同一个对象上的锁时,才会有互斥(非常重要);

附录

在这一节里,列举了一些使用synchronized不妥当的方式,虽然在某些特定的场景下这些方式能正常工作,但还是劝大家要小心。—- 场地那么宽,为什么要在悬崖边跳舞呢?

【1】 不要使用字符串常量作为锁对象

private static final String lock = "LOCK";
public  void sing() {
    synchronized (lock) {
    //TODO
    }
}

在JVM中,字符串常量维护在一个池中,不同类中使用的字符串常量,如果它们的字面值相同,实际上是指向同一个对象的。
套用我们上文中一再强调的同一个对象,大家应该转过弯来了:如果系统中有另外一个类,也是使用了”LOCK”字符串常量作为锁,它们就会形成互斥。

【2】 不要使用Boolean变量作为锁对象
这个大家可以自己验证一下。理由和【1】类似;
【3】 使用类对象做为锁对象时,最好直接使用synchronized(XXX.class) 的形式,不要用synchronized (getClass());
【4】用来作为锁对象的变量,最好只作为锁对象使用,不要对其进行操作,以防改变了对象的引用,使锁失效

//不好的示例
private static  Object lock = new Object();
public  void sing() {
    synchronized (lock) {
        //某些操作
        lock = new  Object();            
    }
}

【5】锁具有可重入性,一个线程已经持有了一个对象的锁时,如果再次请求该对象的锁,会获得成功而不会形成死锁

//这样并不会造成一个线程等待自己释放锁而引起死锁
private static  Object lock = new Object();
   public  void sing() {
       synchronized (lock) {
           //某些操作
           synchronized (lock) {
               //另一些操作 
           }        
       }
   }

根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

本以为写一个篇幅不长的文档很简单,没想到断断续续的写了两天,看来太高估自己了,累! 缓口气再写锁的下一篇吧 :)
错误之处还望大家多多指正。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值