如何理解线程同步
程序代码如下:
public class ThreadPrint { static Thread makeThread(final String id, boolean daemon) { Thread t = new Thread(id) { public void run() { System.out.println(id); } }; t.setDaemon(daemon); t.start(); return t; } public static void main(String[] args) { Thread a = makeThread("A", false); Thread b = makeThread("B", true); System.out.println("END\n"); } }
请选择正确的答案:
(a)总是打印字符A。
(b)总是打印字符B。
(c)从不在END之后打印A。
(d)从不在END之后打印B。
(e)程序可能依次打印B、End和A。
考点:考察求职者对线程同步的理解和认识。
出现频率:★★★★
【面试题解析】
线程共享了相同的资源。但是在某些重要的情况下,一次只能让一个线程来访问共享资源,例如,作为银行账户这样的共享资源,如果多个线程可以同时使用该资源,就会出现银行账户数据安全上的问题。
1.共享变量
要使多个线程在一个程序中有用,必须有某种方法实现线程间互相通信或共享结果,最简单的方法是使用共享变量。使用同步来确保值从一个线程正确传播到另一个线程,以及防止当一个线程正在更新一些相关数据项时,另一个线程看到不一致的中间结果。
2.存在于同一个内存空间中的所有线程
线程与进程有许多共同点,不同的是线程与同一进程中的其他线程共享相同的进程上下文,包括内存空间。只要访问共享变量(静态或实例变量),线程就可以方便地互相交换数据,但必须确保线程以受控的方式访问共享变量,以免它们互相干扰对方的更改。
3.受控访问的同步
为了确保可以在线程之间以受控方式共享数据,Java语言提供了两个关键字:synchronized和volatile。
Synchronized有以下两个重要含义。
• 一次只有一个线程可以执行代码的受保护部分。
• 一个线程更改的数据对于其他线程是可见的。
如果没有同步,数据很容易就处于不一致状态。例如,如果一个线程正在更新两个相关值,而另一个线程正在读取这两个值,有可能在第1 个线程只写了一个值,还没有写另一个值的时候,调度第2个线程运行,这样它就会看到一个旧值和一个新值。
4.确保共享数据更改的可见性
同步可以让用户确保线程看到一致的内存视图。
处理器可以使用高速缓存加速对内存的访问(或者编译器可以将值存储到寄存器中,以便进行更快的访问)。这表示在这样的系统上,对于同一变量,在两个不同处理器上执行的两个线程可能会看到两个不同的值。如果没有正确的同步,线程可能会看到旧的变量值,或者引起其他形式的数据损坏。
volatile比同步更简单,只适合于控制对基本变量(整数、布尔变量等)的单个实例的访问。当一个变量被声明成volatile,任何对该变量的写操作都会绕过高速缓存,直接写入主内存,而任何对该变量的读取也都绕过高速缓存,直接取自主内存。这表示所有线程在任何时候看到的volatile变量值都相同。
5.用锁保护的原子代码块
Volatile对于确保每个线程看到最新的变量值非常有用,但实际上经常需要保护代码片段,同步使用监控器(monitor)或锁的概念,以协调对特定代码块的访问。
每个Java对象都有一个相关的锁,同一时间只能有一个线程持有Java锁。当线程进入synchronized代码块时,线程会阻塞并等待,直到锁可用。当线程处于就绪状态时,并且获得锁后,将执行代码块,当控制退出受保护的代码块,即到达了代码块末尾或者抛出了没有在synchronized块中捕获的异常时,它就会释放该锁。
这样,每次只有一个线程可以执行受给定监控器保护的代码块。从其他线程的角度看,该代码块可以看作是原子的,它要么全部执行,要么根本不执行。
6.简单的同步示例
使用synchronized块可以将一组相关更新作为一个集合来执行,而不必担心其他线程中断或得到意外的结果。以下示例代码将打印“1 0”或“0 1”。如果没有同步,它还会打印“1 1”或“0 0”。
public class SyncExample { private static lockObject = new Object(); private static class Thread1 extends Thread { public void run() { synchronized (lockObject) { x = y = 0; System.out.println(x); } } } private static class Thread2 extends Thread { public void run() { synchronized (lockObject) { x = y = 1; System.out.println(y); } } } public static void main(String[] args) { new Thread1().run(); new Thread2().run(); } }
在这两个线程中都必须使用同步,以便使程序正确工作。
7.Java锁定
Java锁定可以保护许多代码块或方法,每次只有一个线程可以持有锁。
反之,仅仅因为代码块由锁保护并不表示两个线程不能同时执行该代码块。它只表示如果两个线程正在等待相同的锁,则它们不能同时执行该代码。
在以下示例中,两个线程可以同时不受限制地执行setLastAccess()方法中的synchronized块,因为每个线程有一个不同的thingie值。因此,synchronized代码块受到两个正在执行的线程中不同锁的保护。
public class SyncExample { public static class Thingie { private Date lastAccess; public synchronized void setLastAccess(Date date) { this.lastAccess = date; } } public static class MyThread extends Thread { private Thingie thingie; public MyThread(Thingie thingie) { this.thingie = thingie; } public void run() { thingie.setLastAccess(new Date()); } } public static void main() { Thingie thingie1 = new Thingie(), thingie2 = new Thingie(); new MyThread(thingie1).start(); new MyThread(thingie2).start(); } }
8.同步的方法
创建synchronized块的最简单方法是将方法声明成synchronized。这表示在进入方法主体之前,调用者必须获得锁。示例代码如下:
public class Point { public synchronized void setXY(int x, int y) { this.x = x; this.y = y; } }
对于普通的synchronized方法,这个锁是一个对象,将针对它调用方法。对于静态synchronized方法,这个锁是与Class对象相关的监控器,在该对象中声明了方法。
setXY()方法被声明成synchronized,并不表示两个不同的线程不能同时执行setXY()方法,只要它们调用不同的Point实例的setXY()方法,就可同时执行。对于一个Point实例,一次只能有一个线程执行setXY()方法,或Point的任何其他synchronized方法。
9.同步的块
synchronized块的语法比synchronized方法稍微复杂一点,因为还需要显式地指定锁要保护哪个块。
public class Point { public void setXY(int x, int y) { synchronized (this) { this.x = x; this.y = y; } } }
使用this引用作为锁很常见,但这并不是必需的。使用this引用作为锁表示该代码块将与这个类中的synchronized方法使用同一个锁。
由于同步防止了多个线程同时执行一个代码块,因此性能上就有问题,即使是在单处理器系统上,也最好在尽可能小的需要保护的代码块上使用同步。
访问基于堆栈的局部变量从来不需要受到保护,因为它们只能被自己所属的线程访问。
10.大多数类并没有同步
因为同步会带来小小的性能损失,大多数通用类,如java.util中的Collection类,不在内部使用同步。这表示在没有附加同步的情况下,不能在多个线程中使用诸如HashMap这样的类。
面试题中,因为调度程序的确切任务是未定的,所以打印文本的次序也是任意的。打印B的线程是一个后台线程,意味着程序可以在线程设法打印字母之前终止。
参考答案:(a)、(e)。