Java 并发(1)——线程安全

我们对并发一词并不陌生,它通常指多个任务同时执行。实际上这不完全对,“并行”才是真正意义上的同时执行,而“并发”则更偏重于多个任务交替执行。有时候我们会看见一些人一边嘴里嚼着东西一边讲话,这是并行;当然,更文明礼貌的方式是讲话前先把嘴里的东西咽下去,这是并发。并发早期被用来提高单处理器的性能,比如I/O阻塞。在多核处理器被广泛应用的今天,并行和并发的概念已经被模糊了,或许我们不必太过纠结二者之间的微妙差别。

Java的并发是通过多线程实现的,如果有多个处理器,线程调度机制会自动向各处理器分派线程。线程不同于进程,它的级别比进程更低,一个进程可以衍生出多个线程。现代操作系统都是多进程的,不同的程序分属于不同的进程,各进程之间不会共享同一块内存空间。因为进程之间没有交集,所以各进程能够相安无事地运行,这就好比同一栋楼里的不同住户,大家关起门来各过各的,别人家夫妻吵架跟你一点关系都没有。计算机中运行的各个程序都分属于不同的进程,你在使用IDE时不必担心播放器会修改你的代码,也不会担心通讯软件会对IDE有什么影响。但是到了多线程,一切都变得复杂了,原来不同的住户现在要搬到一起合租,卫生间、厨房都变成了公用的。每个线程都共享其所属进程的资源,多线程的困难就在于协调不同线程所驱动的任务之间对共享资源的使用。

既然多线程这么困难,为什么不直接使用多进程呢?一个原因是进程及其昂贵,操作系统会限制进程的数量。另一个原因来自遥远的蛮荒年代,当时一些中古系统并不支持多进程,java为了实现可移植的目的,用多线程实现了并发。

Java的多线程无处不在,然而实际情况是,很少有人真正编写过并发代码,实际上有相当多的技术人员从未写过真正意义上的并发。原因是一些诸如Servlets的框架帮助我们处理了并发问题。

任务与线程

Java的线程是通过Runnable接口实现的,可以这样实现一个线程:

 1 class Task implements Runnable {
 2     private int n = 1;
 3     private String tName = "";
 4     
 5     public Task(String tName) {
 6         this.tName = tName;
 7     }
 8     
 9     @Override
10     public void run() {
11         while(n <= 10) {
12             System.out.print(this.tName + "#" + n + "  ");
13             n++;
14         }
15         System.out.println(this.tName + " is over.");
16     }
17 }
18 
19 public class C_1 {
20     public static void main(String[] args) {
21         Task A = new Task("A");
22         Task B = new Task("B");
23         A.run();
24         B.run();
25         System.out.println("main is over.");
26     }
27 }

 运行结果与顺序执行没什么不同:

这说明实现了Runnable的类实际上与普通类没什么不同,它充其量只是个任务,想要实现并发,必须把任务附着在一个线程上:

1 public class C_1 {
2     public static void main(String[] args) {
3         Thread t1 = new Thread(new Task("A"));
4         Thread t2 = new Thread(new Task("B"));
5         t1.start();
6         t2.start();
7         System.out.println("main is over.");
8     }
9 }

这次才是真正意义上的并发:

 

start()会为线程启动做好必要的准备,之后调用任务的run()方法,让任务运行在线程上。在JDK1.5之后加入了线程管理器,可以不必显示地把任务附着在线程上,同时线程管理器还会自动管理线程的生命周期。

1 public class C_1 {
2     public static void main(String[] args) {        
3         ExecutorService es = Executors.newCachedThreadPool();
4         es.execute(new Task("A"));
5         es.execute(new Task("B"));
6         es.shutdown();
7         System.out.println("main is over.");
8     }
9 }

 shutdown()方法用于阻止向ExecutorService中提交新线程。如果在es.shutdown()时候仍然提交新线程,将会抛出java.util.concurrent.RejectedExecutionException。

JDK8之后加入了lambda表达式,对于一些短小的不需要重用的任务,可以不必单独写成一个类:

 1 public class C_1 {
 2     public static void main(String[] args) {
 3         ExecutorService es = Executors.newCachedThreadPool();
 4         es.execute(new Task("A"));
 5         es.execute(new Task("B"));
 6         es.execute(new Runnable() {
 7             @Override
 8             public void run() {
 9                 System.out.println("I am in lambda.");
10             }
11         });
12         es.shutdown();
13         System.out.println("main is over.");
14     }
15 }

由于每个lambda表达式的初始化都会耽误一点时间,因此在执行短小的运行速度很快的多线程程序时,这种方式往往看不出效果,程序更像是顺序的。

线程安全

我们经常说某个方法是线程安全的。我并不觉得“线程安全”是个易于理解的词。简单地说,如果某个方法是“线程安全”的,那么这个方法在多线程环境下的运行结果也将是可预期的。

 1 import java.util.concurrent.ExecutorService;
 2 import java.util.concurrent.Executors;
 3 
 4 class Task2 implements Runnable {
 5     String tName = "";
 6 
 7     public Task2(String tName) {
 8         this.tName = tName;
 9     }
10 
11     @Override
12     public void run() {
13         for(int i = 1; i <= 10; i++) {
14             System.out.print(tName + "#" + i + " ");
15         }
16     }
17 }
18 
19 public class C_2 {
20     public static void main(String[] args) {
21         ExecutorService es = Executors.newCachedThreadPool();
22         es.execute(new Task2("A"));
23         es.execute(new Task2("B"));
24         es.shutdown();
25     }
26 }

运行结果可能是:

作为一个任务,Task2每次运行都会将10个编号依次打印出来,尽管每次打印的顺序可能有所区别,但我们仍然认为它是可预期的,是线程安全的。

Task2之所以安全,是因为它没有共享的状态。如果加入状态,就很容易把一个原本线程安全的方法变成不安全。

 1 class Task2 implements Runnable {
 2     String tName = "";
 3     static int no = 1;
 4     
 5     public Task2(String tName) {
 6         this.tName = tName;
 7     }
 8 
 9     @Override
10     public void run() {
11         for(int i = 1; i <= 10; i++) {
12             System.out.print(tName + "#" + i + " ");
13             no++;
14         }
15     }
16 }

这里仅仅是对Task2稍加修改,让两个任务共享同一个序号,每次执行循环时都会对no加1。我们预期的效果是每次打印出不同的no值,然而实际的运行结果可能是:

出现了A#9和B#9。其原因是两个线程同时对no产生了竞争,而no++并又是通过多条指令完成的。在no=9时,A线程将其打印出来,之后执行++操作,在执行到一半的时候B进来了,由于++操作并未结束,因此B看见的仍是上一状态。

无状态的程序一定是线程安全的。HTTP是无状态的,处理HTTP请求的servlet也是无状态的,因此servlet是线程安全的。尽管如此,你仍需时刻保持警惕,因为没有任何约束阻止你把一个原本无状态的方法变成有状态的。

1 public class MyServlet extends HttpServlet {
2 
3     private static int no = 1; 
4     
5     @Override
6     protected void service(HttpServletRequest arg0, HttpServletResponse arg1) throws ServletException, IOException {
7         arg0.setAttribute("no", no++);
8     }
9 }

有了共享就有了竞争,此时原本的线程安全也将变成不安全。

单例模式

我曾经面试过很多程序员,问他们知道哪些常用的设计模式,很多人的第一个回答就是单例模式,可见单例模式的深入人心。下面是个典型的单例。

 1 public class Singleton {
 2     private static Singleton sl = null;
 3     
 4     private Singleton() {
 5         System.out.println("OK");
 6     }
 7     
 8     public static Singleton getInstance() {
 9         if(sl == null)
10             sl = new Singleton();
11         return sl;
12     }
13     
14     public static void main(String[] args) {
15         Singleton.getInstance();
16         Singleton.getInstance();
17         Singleton.getInstance();
18 }

Singleton在执行初始化后会打印OK,由于Singleton只会执行一次初始化,因此程序最终仅仅会打印一次OK。然而一切在多线程中变得就不同了。把单例放在线程中:

 1 class Task3 implements Runnable {
 2 
 3     @Override
 4     public void run() {
 5         Singleton.getInstance();
 6     }
 7 }
 8 
 9 public class Singleton {
10     private static Singleton sl = null;
11     
12     private Singleton() {
13         System.out.println("OK");
14     }
15     
16     public static Singleton getInstance() {
17         if(sl == null)
18             sl = new Singleton();
19         return sl;
20     }
21     
22     public static void main(String[] args) {
23         ExecutorService es = Executors.newCachedThreadPool();
24         es.execute(new Task3());
25         es.execute(new Task3());
26         es.execute(new Task3());
27         es.shutdown();
28     }
29 }

3个线程同时发现了sl==null,此时可能会执行3次初始化,打印3次OK。这也成为单例模式被人诟病的原因,虽然可以通过双检查锁和volatile关键字解决上述情况,但代码较为复杂,性能也让人捉急。一个好的方式是使用主动初始化代替单例:

 1 public class Singleton_better {
 2 
 3     private static Singleton_better sl = new Singleton_better();
 4     
 5     public static Singleton_better getInstance() {
 6         return sl;
 7     }
 8     
 9     public Singleton_better() {
10         System.out.println("OK");
11     }
12 }

另一种方式是惰性初始化, 它在解决了线程安全的同时还保留了单例的优点:

 1 public class Single_lazy {
 2         
 3     private static class Handle {
 4         public static Single_lazy sl = new Single_lazy();
 5     }
 6     
 7     public static Single_lazy getInstance() {
 8         return Handle.sl;
 9     }
10 }

  作者:我是8位的

  出处:http://www.cnblogs.com/bigmonkey

  本文以学习、研究和分享为主,如需转载,请联系本人,标明作者和出处,非商业用途! 

  扫描二维码关注公作者众号“我是8位的”

posted on 2019-08-26 11:23  我是8位的  阅读(550)  评论(0编辑  收藏  举报

导航