java多线程(五)-访问共享资源以及加锁机制(synchronized,lock,voliate)

      对于单线程的顺序编程而言,每次只做一件事情,其享有的资源不会产生什么冲突,但是对于多线程编程,这就是一个重要问题了,比如打印机的打印工作,如果两个线程都同时进行打印工作,那这就会产生混乱了。再比如说,多个线程同时访问一个银行账户,多个线程同时修改一个变量的值。这个时候,就很容易产生冲突了。

看一个例子:src\thread_runnable\EvenTest.java

 

 1 class EvenChecker implements Runnable{
 2     private IntGenerator generator;
 3 
 4     public EvenChecker(IntGenerator generator) {
 5         super();
 6         this.generator = generator;
 7     }
 8 
 9 
10     public void run() {
11         // TODO Auto-generated method stub
12         int val = 0;
13         while (!generator.isCanceled()){
14             val = generator.next();
15             if (val%2 != 0){
16                 System.out.println("Error info --->" + val + " not even, threadInfo=" + Thread.currentThread().getName());
17                 generator.cancel();
18             }
19         }
20     }
21     
22     public static void  test(IntGenerator gp, int count) {
23         System.out.println("start test " + count + "  thread") ;
24         ExecutorService exec = Executors.newCachedThreadPool();
25         for (int i=0; i<count; i++){
26             exec.execute(new EvenChecker(gp));
27         }
28         exec.shutdown();
29     }
30     
31     public static void  test(IntGenerator gp) {
32         test(gp, 5);
33     }
34 
35 }//end of "class EventChecker"
36 
37 
38 class IntGenerator {
39     private int currentEvenValue = 0;
40     private volatile boolean canceled = false;
41     
42     /**
43      * 对于顺序执行的程序,该方法内的 currentEvenValue 的值每次都增加2,所以 该方法的返回值用于都为偶数,不可能为奇数。 
44      * @return
45      */
46     public  int next(){
47         ++currentEvenValue;
48 //        Thread.yield();
49         ++currentEvenValue;
50         return currentEvenValue;
51     }
52     public void cancel(){
53         canceled = true;
54     }
55     public boolean isCanceled(){
56         return canceled;
57     }
58 }//end of "class IntGenerator"
59 
60 
61 public class EvenTest {
62     public static void main(String[] args) {
63         // TODO Auto-generated method stub
64         EvenChecker.test(new IntGenerator());
65     }
66 
67 }

先来分析这个代码 ,
在IntGenerator对象中, currentEvenValue值初始值为0,在next()方法里每次加2,然后返回,所以next()方法返回的永远都为偶数,不可能为奇数。而EvenChecker对象默认开启了5个线程,循环获取 IntGenerator对象的next()方法产生的值,并进行判断,如果为奇数,则打印Error info,并停止循环。

当然,这个程序如果是顺序程序,那么永远不可能打印出Error Info,但是实际运行程序,某一次的输出结果如下:

 

 

很快的就产生了奇数的情况,原因就是因为 多个线程以交叉的顺序来修改了 currentEvenValue的值(当然对于多核cpu,可能就是在不同的核上同时运行),在 IntGenerator对象的next()方法中,有可能当currentEvenValue刚加一次时,另一个线程就又进入该方法进行修改。所以导致了产生了 奇数。
这就是多线程共享受限资源,而引起bug的一个明显的例子,我们可以想到,如果在 next()方法的两句++操作语句之间,加一句 Thread.yield()语句,就像下面这样,

1 ++currentEvenValue;
2 Thread.yield();
3 ++currentEvenValue;

那么 next()将会更快的产生奇数。
实际运行某一次输出结果如下:

 

 

解决共享资源竞争
要想避免类似上面demo中出现的不同步问题,做法就是当某一个受限资源在使用过程中加锁,每个线程在访问该资源前,都先检查一下该资源是否加锁了。没有则访问并加锁,否则就等待着,直到锁被(占有该资源的线程)释放了。

这种某个时刻只允许一个线程访问某个共享资源的方法,称为 序列化访问共享资源 的方案,通常这都是通过在代码片段开始时 加入特殊语句来实现的,然后同一时刻,只允许一个线程来访问这个代码片段。因为锁语句产生了一种相互排斥的效果,所以这种机制也常常被称为 互斥量(mutex)。


打印机的例子是很明显的,好几个人都挤在打印机前,都争着抢着打印自己的东西。但是如果某个人使用过程中,能随时被其他人打断抢走,那么最后的结果肯定是乱成一团。而通过加锁机制就可以避免这种情况。第一个挤上去的人,给打印机加了锁,然后开始打印自己的东西,这个时候其他人 虽然围在打印机周围,但是是没办法使用的,只有当第一个人使用完毕了,解锁后,才会有第二个人获得打印机资源,加锁,并开始使用打印机,不过谁会是第二个获得打印机的人,这就不确定了。
Java提供了 synchronized 关键字 来提供加锁支持,当某个线程执行某个被synchronized关键字保护的代码片段的时候,它将先检查其是否加锁,如果没有,则加锁,执行完毕后,再释放锁。如果已经加锁了,那就无法使用这个资源了。

在java中,一切都是对象,不管是要访问打印机,还是输入输出语句,都是要通过调用对象的方法来实现的,所以我们使用synchronized的方式可以是 在定义方法时加锁。
比如

1 class ClassA{
2         synchronized  void g(){ /**  do something  */}
3         synchronized  void f(){ /**  do something  */}
4         void m(){ /**  do something  */}
5 }

我们对ClassA的g()和f()方法进行了加锁。但是需要注意的是,synchronized 加锁,是加在整个ClassA对象上的,也就说,某个线程操作g()方法时,因为g()方法加锁了,其实是ClassA加锁了,所以f()方法也不能被其他线程调用,当然m()方法是可以被其他线程调用的。加锁都是加在对象上,而不是 某个方法上,这样设计是合理的,因为f()和g()既然都是一个对象的方法,那么从设计理念上来讲,他们都应该是属于和同一个受限资源有关系的方法。

具体的加锁,释放锁是JVM来负责的。

我们将上一个 demo中的 IntGenerator对象的next()方法进行加锁。

1 public   synchronized   int   next(){
2         ++currentEvenValue;
3         ++currentEvenValue;
4         return currentEvenValue;
5 }

然后运行代码,输出结果如下

 

从控制台可以看出,程序一直在运行,但是不会再出现奇数,打印出Error info了。
使用 synchronized 关键字可以比较方便的来加锁,而java 5之后,引入了新的对象来加锁。例子如下:

1 void func(){
2 Lock lock = new ReentrantLock();
3         lock.lock();
4         try{
5             //do something
6         }finally{
7             lock.unlock();
8         }
9     }

 

Lock对象可以更加灵活,也可以提供更细粒度的控制,不过synchronized 写起来更加简单方便一些。
如果我们希望加锁的只是方法的部分代码而不是全部(这段代码被称为临界区 critical section),那么也可以使用 synchronized 关键字来操作。

1      void func(){
2         //do something
3         synchronized (this) {
4             //临界区
5         }
6         
7         //do something
8     }

 

我们采用synchronized 来加锁除了防止争夺受限资源这个重要方面之外,其实还有一个方面,那就是:内存可见性.我们不仅希望防止线程A在访问某个对象状态时,另一个线程B同时也在修改该对象状态的这种情况的发生。同时也希望,当线程A修改完该对象的状态后,其他的线程在访问该对象时,都能看到这个变化。这就叫做内存可见性。

而实现内存可见性的方式,除了加锁方式,还有一个 volatile 关键字。
在java当中有个原子操作的概念,原子操作的意思就是 不能被线程调度机制所中断的操作。一般开始该操作,那么在它执行完之前,是不可能进行上下文切换的。比如对于 除了long,double之外的基本类型进行简单操作,就可以称为原子操作。(long,double都是64位,jvm在使用他们的时候,都是将他们当做两个32位的)。原子操作既然不会被线程调度机制中断,那么看起来不需要对它们进行同步控制。但是这种想法对于单核cpu也许使用,但是对于多核cpu,就不是这个样子了。
假设线程A,线程B 都需要访问一个int类型变量count,线程A在cpu的1号核上先执行任务,修改变量count的值,然后存储在了1号核本身的寄存器或者缓存上,然后访问完之后,线程B在cpu的2号核上开始运行,但是请注意这个时候,B读取的count的值是从 主存中读取的(有可能是内存,或者L1 ,L2 cache等).所以线程B读取到的值 和1号核的count值不同了。此时虽然对于count的修改是原子操作,没有被线程中断。但是却不同步。这也被称为 可视性问题。一个线程做出的修改,虽然是原子性的,没有被中断,但是对于其他线程也可能是不可视的。
Volatile关键字就是确保了可视性,当声明一个变量为volatile的,一个线程修改了该变量的值,其他线程也可以看到该修改。添加了volatile关键字的属性,会立刻被写入到主存中,这样就避免了不同步的问题。

 

synchronized和volatile有什么区别呢。
(1) volatile是一种比synchronized更加轻量级的同步机制。volatile不会执行加锁操作,也不会阻塞线程。
(2) 如果代码当中过度依赖volatile,那么将会使代码更脆弱,也更难以理解。
(3) 加锁机制既可以保证可见性又可以保证原子性。而volaitle只确保可见性。
总体来说,需要同步的时候,第一选择应该是synchronized,这是最安全的方式,虽然它可能性能差一些,不过随着jdk本身的优化,加锁机制的性能也在不断提升。

 

这几篇java多线程文章的demo代码下载地址 http://download.csdn.net/detail/yaowen369/9786452

 

-------
作者: www.yaoxiaowen.com
github: https://github.com/yaowen369

posted @ 2017-03-19 18:07  eleven_yw  阅读(1800)  评论(0编辑  收藏  举报