实例变量如balance在线程间是共享的。有的时候,我们真的需要线程级别的变量,不希望共享,也是有办法的。Java里面有ThreadLocal变量。
比如,我们的线程从inventory里面拿东西,上面的程序一次4个。我现在想不同的线程个数拿的个数不同,就可以用一个线程相关的变量。
我们改写一下:
class InventoryWorker implements Runnable{
Inventory inv;
private ThreadLocal<Integer> bag = new ThreadLocal<>();
InventoryWorker(Inventory inv){
this.inv=inv;
}
@Override
public void run() {
Random r = new Random();
bag.set(r.nextInt(10));
for (int i=0; i<10; i++) {
inv.get(bag.get());
try {
Thread.sleep(150);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
我们增加了一个ThreadLocalbag,在线程的run()里面给它赋值。测试程序创建两个线程。
public class InventoryTest {
public static void main(String[] args) {
Inventory inv=new Inventory(1000);
InventoryWorker worker = new InventoryWorker(inv);
Thread t1=new Thread(worker);
Thread t2=new Thread(worker);
t1.start();
t2.start();
}
}
运行后,可以看到,不同的线程的bag变量是不一样的。
回到线程互动。下面我们再看一个例子,对仓库里的物品,有两类线程,一类是不停地往外搬,一类是不停地往里面放,我们看这种情况下如何同步。
先改写Inventory.java:
class Inventory {
private int balance;
public Inventory(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public void setBalance(int balance) {
this.balance = balance;
}
public void ship(int quantity) {
synchronized (this) {
if (balance>=quantity) {
int newBalance = balance - quantity;
this.setBalance(newBalance);
System.out.println(Thread.currentThread().getName()+" Ship, balance : "+getBalance());
}else {
System.out.println(Thread.currentThread().getName()+" Stock is empty");
}
}
}
public void stockin(int quantity) {
synchronized (this) {
if (balance<1000) {
int newBalance = balance + quantity;
this.setBalance(newBalance);
System.out.println(Thread.currentThread().getName()+" Stock in, balance : "+getBalance());
}else {
System.out.println(Thread.currentThread().getName()+" Stock is full");
}
}
}
}
除了ship()往外面搬砖之外,增加了一个stockin()往里面放砖。程序代码没有大变化,只是增加了仓库存量的判断,如果存量不够就不执行了,如果满了也不执行了。
下面是生产者和消费者:
class InventoryConsumer implements Runnable{
Inventory inv;
InventoryConsumer(Inventory inv){
this.inv=inv;
}
@Override
public void run() {
for (int i=0; i<10; i++) {
inv.ship(5);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
消费者循环十次,每次从仓库里面拿5块。
class InventoryProducer implements Runnable{
Inventory inv;
InventoryProducer(Inventory inv){
this.inv=inv;
}
@Override
public void run() {
for (int i=0; i<10; i++) {
inv.stockin(3);
try {
Thread.sleep(150);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
生产者也是循环十次,每次往仓库里面放3个。
最后用一个客户程序进行试验:
public class InventoryTest {
public static void main(String[] args) {
Inventory inv=new Inventory(20);
Thread t1=new Thread(new InventoryProducer(inv));
Thread t3=new Thread(new InventoryConsumer(inv));
t1.start();
t3.start();
}
}
给仓库一个初始库存为20,生产者和消费者各自运行。初看起来,也没有什么大问题,数量也不会弄乱。因为ship()和stockin()都加上了同步。运行结果如下:
Thread-0 Stock in, balance : 23
Thread-1 Ship, balance : 18
Thread-1 Ship, balance : 13
Thread-0 Stock in, balance : 16
Thread-1 Ship, balance : 11
Thread-0 Stock in, balance : 14
Thread-1 Ship, balance : 9
Thread-1 Ship, balance : 4
Thread-0 Stock in, balance : 7
Thread-1 Ship, balance : 2
Thread-0 Stock in, balance : 5
Thread-1 Ship, balance : 0
Thread-1 Stock is empty
Thread-0 Stock in, balance : 3
Thread-1 Stock is empty
Thread-0 Stock in, balance : 6
Thread-1 Ship, balance : 1
Thread-0 Stock in, balance : 4
Thread-0 Stock in, balance : 7
Thread-0 Stock in, balance : 10
最后的结果是10。不对呀。初始化数量为20,存了十次共30个,拿了十次共50个,最后的库存应该是0啊。我们仔细分析结果。有两次打印Thread-1 Stock is empty,说明当时检查库存不足了,就没有执行。
这当然解释了这个结果。不过我们的本意却不是这样的。我们是想让程序真的执行十次存放和外拿操作的。也就是说,一旦库存不足,不应该跳过不执行,而应该是等待生产者放足了再执行。我们为了实现这个效果,需要用到wait()和notify()。
Inventory程序修改如下:
class Inventory {
private int balance;
public Inventory(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public void setBalance(int balance) {
this.balance = balance;
}
public void ship(int quantity) {
synchronized (this) {
while (balance<quantity) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int newBalance = balance - quantity;
this.setBalance(newBalance);
System.out.println(Thread.currentThread().getName()+" Ship, balance : "+getBalance());
notify();
}
}
public void stockin(int quantity) {
synchronized (this) {
while (balance>=1000) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int newBalance = balance + quantity;
this.setBalance(newBalance);
System.out.println(Thread.currentThread().getName()+" Stock in, balance : "+getBalance());
notify();
}
}
}
当库存数量不足的时候,就让线程死等,什么时候数量够了,就执行操作。
运行结果如下:
Thread-0 Stock in, balance : 23
Thread-1 Ship, balance : 18
Thread-1 Ship, balance : 13
Thread-0 Stock in, balance : 16
Thread-1 Ship, balance : 11
Thread-0 Stock in, balance : 14
Thread-1 Ship, balance : 9
Thread-1 Ship, balance : 4
Thread-0 Stock in, balance : 7
Thread-1 Ship, balance : 2
Thread-0 Stock in, balance : 5
Thread-1 Ship, balance : 0
Thread-0 Stock in, balance : 3
Thread-0 Stock in, balance : 6
Thread-1 Ship, balance : 1
Thread-0 Stock in, balance : 4
Thread-0 Stock in, balance : 7
Thread-1 Ship, balance : 2
Thread-0 Stock in, balance : 5
Thread-1 Ship, balance : 0
这是我们想要的结果。这个结果是用了wait()和notify()协调了生产者和消费者的行为才导致的。解释一下,当前消费者线程拿到锁运行到临界区的时候,遇到wait()就进行阻塞状态,不再往下执行,同时把锁交回去。另一个生产者线程拿到锁运行到临界区的时候,遇到notify()就会唤醒阻塞的线程,线程继续运行,判断库存是否足,不足继续wait(),足够了就往下执行。wait()和notify()作为一对方法,同时起作用,执行阻塞和唤醒,达到生产者消费者协调的目的。
程序里wait()外面包含的代码块由if()改成了while(),这点是必须的,否则可能会有虚假唤醒的情况。因为wait()阻塞被唤醒后线程是接着往下执行的,如果用if-else结构,那就直接跳过了,而我们的本意是要执行else,所以不能用else。那只是去掉else,而保留if行不行呢?也不行。因为多线程程序,我们可以创建多个消费者和生产者线程,不仅仅是上面代码里面的一个消费者和一个生产者,多个消费者线程可能会被同时唤醒,如果没头没脑执行操作就会出现错误数据。大家可以自己试一下。
线程有多个,运行次序不定,所以要限制使用场合,莫弄乱了。特别是在多个线程要共享一些数据的时候,更要小心翼翼,隔离和同步都要小心。最理想的情况是线程之间不共享任何东西,完全是独立的。但是这是不可能的,我们要被迫小心翼翼写程序,很累。仔细再仔细,避免混乱或者互相死锁。
我们看一个死锁的例子,跑两个线程,分别用o1和o2来互锁,代码如下:
public class DeadLockTest {
static Object o1 = new Object();
static Object o2 = new Object();
public static void main(String[] args) {
Thread t1=new Thread(new DeadLock1());
Thread t2=new Thread(new DeadLock2());
t1.start();
t2.start();
}
}
public class DeadLock1 implements Runnable {
@Override
public void run() {
while(true) {
synchronized(DeadLockTest.o1) {
System.out.println("thread 0 - lock 1");
synchronized(DeadLockTest.o2) {
System.out.println("thread 0 - lock 2");
}
}
}
}
}
DeadLock1是线程0,先锁o1,再锁o2。
public class DeadLock2 implements Runnable {
@Override
public void run() {
while(true) {
synchronized(DeadLockTest.o2) {
System.out.println("thread 1 - lock 2");
synchronized(DeadLockTest.o1) {
System.out.println("thread 1 - lock 1");
}
}
}
}
}
DeadLock2是线程1,先锁o2,再锁o1。
运行一下,结果是:
thread 0 - lock 1
thread 1 - lock 2
但是程序没有结束,在死等。
我们可以观察一下。先用 jps命令找到进程号,然后用jstack看,结果是:
Java stack information for the threads listed above:
===================================================
"Thread-1":
at DeadLock2.run(DeadLock2.java:9)
- waiting to lock <0x00000000d60d64c8> (a java.lang.Object)
- locked <0x00000000d60d64d8> (a java.lang.Object)
at java.lang.Thread.run(Unknown Source)
"Thread-0":
at DeadLock1.run(DeadLock1.java:9)
- waiting to lock <0x00000000d60d64d8> (a java.lang.Object)
- locked <0x00000000d60d64c8> (a java.lang.Object)
at java.lang.Thread.run(Unknown Source)
Found 1 deadlock.
明显看出了Thread-0锁住了0x00000000d60d64c8,等着0x00000000d60d64d8,而与此同时Thread-1锁住了0x00000000d60d64d8,而等着0x00000000d60d64c8。陷入了死锁。
“有的线程活着,它已经死了。有的线程死了,它还活着。”Sun技术研发中心一个同事这么说过。多线程程序比较难以编写,不建议自己直接通过new Thread()创建线程,也不建议自己直接用wait()和notify(),而要用更保险的方法。答案就在java.util.concurrent中。
里面有一个BlockingQueue,能够让我们很方便地实现生产者消费者模型,不需要自己手工同步。我们用一个简单的例子说明。
先定义一个消费者,代码如下(BQConsumer.java):
import java.util.concurrent.BlockingQueue;
class BQConsumer extends Thread {
private final BlockingQueue<String> queue;
public BQConsumer(BlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
String str = this.queue.take();
System.out.println("Consumer took: " + str);
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
}
消费者线程里面用到了一个BlockingQueue,要从里面拿东西就直接用queue.take(),别的不用管。
再定义生产者,代码如下(BQProducer.java):
import java.util.concurrent.BlockingQueue;
class BQProducer extends Thread {
private final BlockingQueue<String> queue;
public BQProducer(BlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
this.queue.put("goods");
System.out.println("Stock in : goods");
Thread.sleep(1000);
}
catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
}
生产者线程里面用到了一个BlockingQueue,要往里面放东西就直接用queue.put(),别的不用管。
简化了好多。
使用的代码一样的,代码如下(BQTest.java):
public class BQTest {
public static void main(String[] args) {
BlockingQueue<String> queue = new LinkedBlockingQueue<>(20);
new BQProducer(queue).start();
new BQProducer(queue).start();
new BQConsumer(queue).start();
new BQConsumer(queue).start();
}
}
这种写法,又好理解又好用。君子善假于器也。
BlockingQueue我们在讲collection集合类的时候再进一步介绍,这里主要就着多线程来讲。
上面说了线程之间同步的机制就是锁。我们到现在用的synchronized锁都是悲观锁,怕数据改乱了,拿到锁就独占,这种严防死守的方式保证数据正确。还有一种乐观锁,它认为大部分的时候不会都同时修改数据,只是有的读有的写,不用一上来就严防死守,而是可以考虑真正写的时候去试,有了冲突再解决。这样平均的吞吐量比较好,并发性高。
CAS是实现乐观锁的一种方式。Java8之后容器框架里面用了CAS,如ConcurrentHashMap。CAS意思是Compare And Swap,用了3个基本操作数:内存里的值V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址中的实际值V相同时,才将值修改为B。
举一个例子,设定初始内存里面的值V=10。现在线程1启动了,它想把值加1,所以对线程1来讲,旧的预期值就是10,要修改的新值是11。说时迟那时快,就在线程1要更新的时候,线程2来了,它把内存的值先变成了11。线程1要着手更新了,它检查一下这个时候的V(此时已经是11了),然后比较A(10),一看不相等,就提交失败。线程1就重新从内存拿值,得到V=11,并且A=11,再进行加1计算,于是B=12,这个时候线程1再次提交,幸运的是再次比较的时候V=A,所以就把新值12更新进去。
我们回顾两个线程从仓库里面搬砖的例子,以前我们用synchronized做的。现在我们改成CAS来实现。代码如下:
class Inventory {
private AtomicInteger balance;
public Inventory(int balance) {
this.balance = new AtomicInteger(balance);
}
public void get(int quantity) {
int oldValue = balance.get();
int newBalance = oldValue - quantity;
while (!balance.compareAndSet(oldValue, newBalance)) {
oldValue = balance.get();
}
System.out.println(Thread.currentThread().getName()+" got :"+quantity +", balance : "+getBalance());
}
}
class InventoryWorker implements Runnable{
Inventory inv;
InventoryWorker(Inventory inv){
this.inv=inv;
}
@Override
public void run() {
for (int i=0; i<10; i++) {
inv.get(4);
try {
Thread.sleep(150);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class InventoryTest {
public static void main(String[] args) {
Inventory inv=new Inventory(1000);
Thread t1=new Thread(new InventoryWorker(inv));
Thread t2=new Thread(new InventoryWorker(inv));
t1.start();
t2.start();
}
}
新程序里面,我们用了AtomicInteger,进行原子操作。实际更新的时候,用得下面的代码:
while (!balance.compareAndSet(oldValue, newBalance)) {
oldValue = balance.get();
}
按照CAS的机制进行更新。
我们再看看java.util.concurrent里面还提供了什么工具。其中最重要的就是提供了Executor Service。通过它,能执行异步线程,还能管理线程池。具体的类就是Executors,这是负责线程的使用与调度的工具类,我们一般从它开始入手。
我们先看看怎么执行线程。它不仅仅能执行Runnable,还能执行Callable。
先看一个简单的例子,代码如下(ExecutorsTest1.java):
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorsTest1 {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(() -> {
System.out.println("Hello Thread!");
});
}
}
先用 Executors.newSingleThreadExecutor()创建了一个单线程的executor service,再直接提交执行一个线程。运行一下,打印出来了结果,但是这个程序不停止。是的,Executors必须显式的停止,否则它会一直等着新的任务。executor.shutdown()可以用来停止Java进程,并且是温柔地停止,所谓“温柔”,就是它会等待提交的线程执行完毕之后再停止。自然也有简单粗暴的办法直接停止,就是executor.shutdownNow()。
再看一个执行Callable读返回值的简单例子,代码如下(ExecutorsTest2.java):
public class ExecutorsTest2 {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> ft = executor.submit(() -> {
System.out.println("Hello Thread!");
return "sucess";
});
try {
System.out.println("return : " + ft.get());
} catch (Exception e) {
e.printStackTrace();
}
executor.shutdown();
}
}
再看看线程池的概念。在有大量线程需要创建的时候,线程本身的创建销毁就成了一个负担,所以我们这个时候会使用线程池来复用。我们还是建议用系统提供的方法,不要手工自己创建,提高性能,并避免内存溢出等错误,简化管理。对服务器端的程序,大体上都要这么用。Executors提供了几个方法创建线程池,newCachedThreadPool()用来创建大小按需变化的线程池,newFixedThreadPool()创建固定大小的线程池(超出的线程进入等待队列),newScheduledThreadPool()创建可以定时执行的线程池。这几个方法按照常规情况创建线程池,一般情况下可以用,比较便利。但是有些情况下,还是要手工创建线程池比较合适,new ThreadPoolExecutor(),可以使用的参数比较多,自定义策略,下面会讲到。
先看一个简单的例子。
public class TestThreadPool {
public static void main(String[] args) {
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
for (int i = 0; i < 20; i++) {
final int index = i;
fixedThreadPool.execute(()-> {
try {
System.out.println(index);
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
);
}
}
}
用Executors.newFixedThreadPool(3)创建了大小为3的线程池,连续运行20个线程。我们可以明显看出是每三秒打印出三个数字,意味着三个线程同时在干活儿,别的线程等待。
没头没脑地增加线程数,有害无益,因为线程的管理和调度本身也是要耗费资源的。线程池过大,大量的线程将在相对更少的CPU和有限的内存资源上竞争,这不仅影响并发性能,还会因过高的内存消耗导致溢出;线程池过小,将导致处理器得不到充分利用,降低吞吐率。
经验值呢,我问过一些人,腾讯后台的研发人员曾经告诉过我,根据他们的经验,按照服务器上的CPU和任务特点来安排是合理的,比如对于计算密集型任务,应该将线程数设置为CPU的处理个数+1会比较优化。由于线程有执行栈等内存消耗,创建过多的线程不会加快计算速度,反而会消耗更多的内存空间;另一方面线程过多,频繁切换线程上下文也会影响线程池的性能。而CachedThreadPool能有效提高IO密集型任务的吞吐量。有个公式Threads= Ncpu * Ucpu * (1 + W/C)可以估算,Ncpu是CPU个数,Ucpu是预估的CPU的使用率,W是线程平均等待时间,C是任务执行时间。
我前面提到过,按照Executors new出来的线程池,用的默认参数设置,有的时候不合适,所以需要了解一下手工如何创建ThreadPoolExecutor。
构造函数为:
ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
参数说明:
corePoolSize - the number of threads to keep in the pool, even if they are idle, unless allowCoreThreadTimeOut is set
maximumPoolSize - the maximum number of threads to allow in the pool
keepAliveTime - when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating.
unit - the time unit for the keepAliveTime argument
workQueue - the queue to use for holding tasks before they are executed. This queue will hold only the Runnable tasks submitted by the execute method.
threadFactory - the factory to use when the executor creates a new thread
handler - the handler to use when execution is blocked because the thread bounds and queue capacities are reached
说明:
A ThreadPoolExecutor 自动调整池子大小 (see getPoolSize()),依据的是参数corePoolSize (see getCorePoolSize()) 和 maximumPoolSize (see getMaximumPoolSize())。
一个新的任务提交执行后execute(Runnable), 如果这时小于corePoolSize个线程在运行, 就会创建一个新的线程处理这个执行请求(即使这个时候有休眠的线程也不管)。
如果这是有大于corePoolSize但是小于maximumPoolSize个线程在运行,会先放到队列workQueue中,只有当队列workQueue满了之后才创建新的线程。
如果当前有超过corePoolSize个线程,超过的线程如果休眠时间达到了keepAliveTime,就会被终止掉。
提交任务的时候,如果线程数达到了maximumPoolSize 并且workQueue也满了,就会被拒绝掉。拒绝有不同的策略选择。
corePoolSize,maximumPoolSize,Queue的大小,需要根据情况进行设置。
我们最后用一个例子演示,代码如下(TestThreadPool.java):
public class TestThreadPool {
public static void main(String[] args) {
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(3, 6, 5, TimeUnit.SECONDS, new LinkedBlockingDeque<>(5));
ExecutorCompletionService<String> executorCompletionService =
new ExecutorCompletionService<>(threadPoolExecutor);
for (int i = 0; i < 20; i++) {
try {
executorCompletionService.submit(()-> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"testtask"+i);
System.out.print(" New task: testtask" + i);
System.out.print(" ActiveCount: " + threadPoolExecutor.getActiveCount());
System.out.print(" poolSize: " + threadPoolExecutor.getPoolSize());
System.out.print(" queueSize: " + threadPoolExecutor.getQueue().size());
System.out.println(" taskCount: " + threadPoolExecutor.getTaskCount());
} catch (RejectedExecutionException e) {
System.out.println("Reject:" + i);
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
threadPoolExecutor.shutdown();
}
}
手工创建线程池,提交20个任务,观察线程池的变化。记录活动任务数,池子大小,队列大小,以及任务数。
运行结果如下:
New task: testtask0 ActiveCount: 1 poolSize: 1 queueSize: 0 taskCount: 1
New task: testtask1 ActiveCount: 2 poolSize: 2 queueSize: 0 taskCount: 2
New task: testtask2 ActiveCount: 3 poolSize: 3 queueSize: 0 taskCount: 3
New task: testtask3 ActiveCount: 3 poolSize: 3 queueSize: 1 taskCount: 4
New task: testtask4 ActiveCount: 3 poolSize: 3 queueSize: 2 taskCount: 5
New task: testtask5 ActiveCount: 3 poolSize: 3 queueSize: 3 taskCount: 6
New task: testtask6 ActiveCount: 3 poolSize: 3 queueSize: 4 taskCount: 7
New task: testtask7 ActiveCount: 3 poolSize: 3 queueSize: 5 taskCount: 8
New task: testtask8 ActiveCount: 4 poolSize: 4 queueSize: 5 taskCount: 9
New task: testtask9 ActiveCount: 5 poolSize: 5 queueSize: 5 taskCount: 10
New task: testtask10 ActiveCount: 6 poolSize: 6 queueSize: 5 taskCount: 11
Reject:11
Reject:12
Reject:13
Reject:14
New task: testtask15 ActiveCount: 6 poolSize: 6 queueSize: 5 taskCount: 12
New task: testtask16 ActiveCount: 6 poolSize: 6 queueSize: 5 taskCount: 13
New task: testtask17 ActiveCount: 6 poolSize: 6 queueSize: 5 taskCount: 14
Reject:18
Reject:19
关于Java并发编程,推荐大家好好看一本书《Java Concurrency in Practice》,这是Java并发编程的圣经。我们看看作者名单,这些如雷贯耳的名字,就能领略了,Brian Goetz / Tim Peierls / Joshua Bloch / Joseph Bowbeer / David Holmes / Doug Lea。它们都是Java Community Process JSR 166专家组(并发工具)的主要成员,并在其他很多JCP专家组里任职。Brian Goetz有20多年的软件咨询行业经验,并著有大量Java开发的文章。Tim Peierls是“现代多处理器”的典范人物。Joseph Bowbeer是一个Java ME专家,他对并发编程的兴趣始于Apollo计算机时代。David Holmes是《The Java Programming Language》一书的合著者,任职于Sun公司。Joshua Bloch是Google公司的首席Java架构师,《Effective Java》一书的作者。Doug Lea是《Concurrent Programming》一书的作者,纽约州立大学 Oswego分校的计算机科学教授,并发计算的权威。
读书就要读好书,读经典的书,是获益最大最快的方式,在市面上和百度上乱找一气,花费的时间与得到的收获不成比例。在阅读好书的过程中逐渐形成自己的品味和格,杨振宁说过,学者是先有taste,后有style。
读书乃人生一大快事。读这些名家经典,经常会有如沐春风的感觉,有时候不禁掩卷赞叹:春风大雅能容物,秋水文章不染尘!