申明:转载自 https://www.cnblogs.com/dennyzhangdd/p/8981982.html
感谢原博主的分享,看到这个写的真好,直接转载来,学习了。
另外也推荐另外一篇GuavaCache的文章:https://ketao1989.github.io/2014/12/19/Guava-Cache-Guide-And-Implement-Analyse/#top
正文
一、引子
缓存有很多种解决方案,常见的是:
1.存储在内存中 : 内存缓存顾名思义直接存储在JVM内存中,JVM宕机那么内存丢失,读写速度快,但受内存大小的限制,且有丢失数据风险。
2.存储在磁盘中: 即从内存落地并序列化写入磁盘的缓存,持久化在磁盘,读写需要IO效率低,但是安全。
3.内存+磁盘组合方式:这种组合模式有很多成熟缓存组件,也是高效且安全的策略,比如redis。
本文分析常用的内存缓存:google cache。源码包:com.google.guava:guava:22.0 jar包下的pcom.google.common.cache包,适用于高并发读写场景,可自定义缓存失效策略。
二、使用方法
2.1 CacheBuilder有3种失效重载模式
1.expireAfterWrite
当 创建 或 写之后的 固定 有效期到达时,数据会被自动从缓存中移除,源码注释如下:
1 /**指明每个数据实体:当 创建 或 最新一次更新 之后的 固定值的 有效期到达时,数据会被自动从缓存中移除
2 * Specifies that each entry should be automatically removed from the cache once a fixed duration
3 * has elapsed after the entry's creation, or the most recent replacement of its value.
4 *当间隔被设置为0时,maximumSize设置为0,忽略其它容量和权重的设置。这使得测试时 临时性地 禁用缓存且不用改代码。
5 * <p>When {@code duration} is zero, this method hands off to {@link #maximumSize(long)
6 * maximumSize}{@code (0)}, ignoring any otherwise-specified maximum size or weight. This can be
7 * useful in testing, or to disable caching temporarily without a code change.
8 *过期的数据实体可能会被Cache.size统计到,但不能进行读写,数据过期后会被清除。
9 * <p>Expired entries may be counted in {@link Cache#size}, but will never be visible to read or
10 * write operations. Expired entries are cleaned up as part of the routine maintenance described
11 * in the class javadoc.
12 *
13 * @param duration the length of time after an entry is created that it should be automatically
14 * removed
15 * @param unit the unit that {@code duration} is expressed in
16 * @return this {@code CacheBuilder} instance (for chaining)
17 * @throws IllegalArgumentException if {@code duration} is negative
18 * @throws IllegalStateException if the time to live or time to idle was already set
19 */
20 public CacheBuilder<K, V> expireAfterWrite(long duration, TimeUnit unit) {
21 checkState(
22 expireAfterWriteNanos == UNSET_INT,
23 "expireAfterWrite was already set to %s ns",
24 expireAfterWriteNanos);
25 checkArgument(duration >= 0, "duration cannot be negative: %s %s", duration, unit);
26 this.expireAfterWriteNanos = unit.toNanos(duration);
27 return this;
28 }
2.expireAfterAccess
指明每个数据实体:当 创建 或 写 或 读 之后的 固定值的有效期到达时,数据会被自动从缓存中移除。读写操作都会重置访问时间,但asMap方法不会。源码注释如下:
1 /**指明每个数据实体:当 创建 或 更新 或 访问 之后的 固定值的有效期到达时,数据会被自动从缓存中移除。读写操作都会重置访问时间,但asMap方法不会。
2 * Specifies that each entry should be automatically removed from the cache once a fixed duration
3 * has elapsed after the entry's creation, the most recent replacement of its value, or its last
4 * access. Access time is reset by all cache read and write operations (including
5 * {@code Cache.asMap().get(Object)} and {@code Cache.asMap().put(K, V)}), but not by operations
6 * on the collection-views of {@link Cache#asMap}.
7 * 后面的同expireAfterWrite
8 * <p>When {@code duration} is zero, this method hands off to {@link #maximumSize(long)
9 * maximumSize}{@code (0)}, ignoring any otherwise-specified maximum size or weight. This can be
10 * useful in testing, or to disable caching temporarily without a code change.
11 *
12 * <p>Expired entries may be counted in {@link Cache#size}, but will never be visible to read or
13 * write operations. Expired entries are cleaned up as part of the routine maintenance described
14 * in the class javadoc.
15 *
16 * @param duration the length of time after an entry is last accessed that it should be
17 * automatically removed
18 * @param unit the unit that {@code duration} is expressed in
19 * @return this {@code CacheBuilder} instance (for chaining)
20 * @throws IllegalArgumentException if {@code duration} is negative
21 * @throws IllegalStateException if the time to idle or time to live was already set
22 */
23 public CacheBuilder<K, V> expireAfterAccess(long duration, TimeUnit unit) {
24 checkState(
25 expireAfterAccessNanos == UNSET_INT,
26 "expireAfterAccess was already set to %s ns",
27 expireAfterAccessNanos);
28 checkArgument(duration >= 0, "duration cannot be negative: %s %s", duration, unit);
29 this.expireAfterAccessNanos = unit.toNanos(duration);
30 return this;
31 }
3.refreshAfterWrite
指明每个数据实体:当 创建 或 写 之后的 固定值的有效期到达时,数据会被自动刷新(注意不是删除是异步刷新,不会阻塞读取,先返回旧值,异步重载到数据返回后复写新值)。源码注释如下:
1 /**指明每个数据实体:当 创建 或 更新 之后的 固定值的有效期到达时,数据会被自动刷新。刷新方法在LoadingCache接口的refresh()申明,实际最终调用的是CacheLoader的reload()
2 * Specifies that active entries are eligible for automatic refresh once a fixed duration has
3 * elapsed after the entry's creation, or the most recent replacement of its value. The semantics
4 * of refreshes are specified in {@link LoadingCache#refresh}, and are performed by calling
5 * {@link CacheLoader#reload}.
6 * 默认reload是同步方法,所以建议用户覆盖reload方法,否则刷新将在无关的读写操作间操作。
7 * <p>As the default implementation of {@link CacheLoader#reload} is synchronous, it is
8 * recommended that users of this method override {@link CacheLoader#reload} with an asynchronous
9 * implementation; otherwise refreshes will be performed during unrelated cache read and write
10 * operations.
11 *
12 * <p>Currently automatic refreshes are performed when the first stale request for an entry
13 * occurs. The request triggering refresh will make a blocking call to {@link CacheLoader#reload}
14 * and immediately return the new value if the returned future is complete, and the old value
15 * otherwise.触发刷新操作的请求会阻塞调用reload方法并且当返回的Future完成时立即返回新值,否则返回旧值。
16 *
17 * <p><b>Note:</b> <i>all exceptions thrown during refresh will be logged and then swallowed</i>.
18 *
19 * @param duration the length of time after an entry is created that it should be considered
20 * stale, and thus eligible for refresh
21 * @param unit the unit that {@code duration} is expressed in
22 * @return this {@code CacheBuilder} instance (for chaining)
23 * @throws IllegalArgumentException if {@code duration} is negative
24 * @throws IllegalStateException if the refresh interval was already set
25 * @since 11.0
26 */
27 @GwtIncompatible // To be supported (synchronously).
28 public CacheBuilder<K, V> refreshAfterWrite(long duration, TimeUnit unit) {
29 checkNotNull(unit);
30 checkState(refreshNanos == UNSET_INT, "refresh was already set to %s ns", refreshNanos);
31 checkArgument(duration > 0, "duration must be positive: %s %s", duration, unit);
32 this.refreshNanos = unit.toNanos(duration);
33 return this;
34 }
2.2 测试验证
1)定义一个静态的LoadingCache,用cacheBuilder构造缓存,分别定义了同步load(耗时2秒)和异步reload(耗时2秒)方法。
2)在main方法中,往缓存中设置值,定义3个线程,用CountDownLatch倒计时器模拟3个线程并发读取缓存,最后在主线程分别5秒、0.5秒、2秒时get缓存。
测试代码如下:
1 package guava;
2
3 import com.google.common.cache.CacheBuilder;
4 import com.google.common.cache.CacheLoader;
5 import com.google.common.cache.LoadingCache;
6 import com.google.common.util.concurrent.ListenableFuture;
7 import com.google.common.util.concurrent.ListeningExecutorService;
8 import com.google.common.util.concurrent.MoreExecutors;
9
10 import java.util.Date;
11 import java.util.Random;
12 import java.util.concurrent.Callable;
13 import java.util.concurrent.CountDownLatch;
14 import java.util.concurrent.Executors;
15 import java.util.concurrent.TimeUnit;
16
17 /**
18 * @ClassName guava.LoadingCacheTest
19 * @Description 注意refresh并不会主动刷新,而是被检索触发更新value,且随时可返回旧值
20 * @Author denny
21 * @Date 2018/4/28 下午12:10
22 */
23 public class LoadingCacheTest {
24
25 // guava线程池,用来产生ListenableFuture
26 private static ListeningExecutorService service = MoreExecutors.listeningDecorator(
27 Executors.newFixedThreadPool(10));
28
29 /**
30 * 1.expireAfterWrite:指定时间内没有创建/覆盖时,会移除该key,下次取的时候触发"同步load"(一个线程执行load)
31 * 2.refreshAfterWrite:指定时间内没有被创建/覆盖,则指定时间过后,再次访问时,会去刷新该缓存,在新值没有到来之前,始终返回旧值
32 * "异步reload"(也是一个线程执行reload)
33 * 3.expireAfterAccess:指定时间内没有读写,会移除该key,下次取的时候从loading中取
34 * 区别:指定时间过后,expire是remove该key,下次访问是同步去获取返回新值;
35 * 而refresh则是指定时间后,不会remove该key,下次访问会触发刷新,新值没有回来时返回旧值
36 *
37 * 同时使用:可避免定时刷新+定时删除下次访问载入
38 */
39 private static final LoadingCache<String, String> cache = CacheBuilder.newBuilder()
40 .maximumSize(1000)
41 //.refreshAfterWrite(1, TimeUnit.SECONDS)
42 .expireAfterWrite(1, TimeUnit.SECONDS)
43 //.expireAfterAccess(1,TimeUnit.SECONDS)
44 .build(new CacheLoader<String, String>() {
45 @Override
46 public String load(String key) throws Exception {
47 System.out.println(Thread.currentThread().getName() +"==load start=="+",时间=" + new Date());
48 // 模拟同步重载耗时2秒
49 Thread.sleep(2000);
50 String value = "load-" + new Random().nextInt(10);
51 System.out.println(
52 Thread.currentThread().getName() + "==load end==同步耗时2秒重载数据-key=" + key + ",value="+value+",时间=" + new Date());
53 return value;
54 }
55
56 @Override
57 public ListenableFuture<String> reload(final String key, final String oldValue)
58 throws Exception {
59 System.out.println(
60 Thread.currentThread().getName() + "==reload ==异步重载-key=" + key + ",时间=" + new Date());
61 return service.submit(new Callable<String>() {
62 @Override
63 public String call() throws Exception {
64 /* 模拟异步重载耗时2秒 */
65 Thread.sleep(2000);
66 String value = "reload-" + new Random().nextInt(10);
67 System.out.println(Thread.currentThread().getName() + "==reload-callable-result="+value+ ",时间=" + new Date());
68 return value;
69 }
70 });
71 }
72 });
73
74 //倒计时器
75 private static CountDownLatch latch = new CountDownLatch(1);
76
77 public static void main(String[] args) throws Exception {
78
79 System.out.println("启动-设置缓存" + ",时间=" + new Date());
80 cache.put("name", "张三");
81 System.out.println("缓存是否存在=" + cache.getIfPresent("name"));
82 //休眠
83 Thread.sleep(2000);
84 //System.out.println("2秒后"+",时间="+new Date());
85 System.out.println("2秒后,缓存是否存在=" + cache.getIfPresent("name"));
86 //启动3个线程
87 for (int i = 0; i < 3; i++) {
88 startThread(i);
89 }
90
91 // -1直接=0,唤醒所有线程读取缓存,模拟并发访问缓存
92 latch.countDown();
93 //模拟串行读缓存
94 Thread.sleep(5000);
95 System.out.println(Thread.currentThread().getName() + "休眠5秒后,读缓存="+cache.get("name")+",时间=" + new Date());
96 Thread.sleep(500);
97 System.out.println(Thread.currentThread().getName() + "距离上一次读0.5秒后,读缓存="+cache.get("name")+",时间=" + new Date());
98 Thread.sleep(2000);
99 System.out.println(Thread.currentThread().getName() + "距离上一次读2秒后,读缓存="+cache.get("name")+",时间=" + new Date());
100 }
101
102 private static void startThread(int id) {
103 Thread t = new Thread(new Runnable() {
104 @Override
105 public void run() {
106 try {
107 System.out.println(Thread.currentThread().getName() + "...begin" + ",时间=" + new Date());
108 //休眠,当倒计时器=0时唤醒线程
109 latch.await();
110 //读缓存
111 System.out.println(
112 Thread.currentThread().getName() + "并发读缓存=" + cache.get("name") + ",时间=" + new Date());
113 } catch (Exception e) {
114 e.printStackTrace();
115 }
116 }
117 });
118
119 t.setName("Thread-" + id);
120 t.start();
121 }
122 }
结果分析
1.expireAfterWrite:当 创建 或 写 之后的 有效期到达时,数据会被自动从缓存中移除
启动-设置缓存,时间=Thu May 17 17:55:36 CST 2018-->主线程启动,缓存创建完毕并设值,即触发写缓存
缓存是否存在=张三
2秒后,缓存是否存在=null--》设定了1秒自动删除缓存,2秒后缓存不存在
Thread-0...begin,时间=Thu May 17 17:55:38 CST 2018--》38秒时,启动3个线程模拟并发读:三个线程读缓存,由于缓存不存在,阻塞在get方法上,等待其中一个线程去同步load数据
Thread-1...begin,时间=Thu May 17 17:55:38 CST 2018
Thread-2...begin,时间=Thu May 17 17:55:38 CST 2018
Thread-1==load start==,时间=Thu May 17 17:55:38 CST 2018---线程1,同步载入数据load()
Thread-1==load end==同步耗时2秒重载数据-key=name,value=load-2,时间=Thu May 17 17:55:40 CST 2018--线程1,同步载入数据load()完毕!,即40秒时写入数据:load-2
Thread-0并发读缓存=load-2,时间=Thu May 17 17:55:40 CST 2018---线程1同步载入数据load()完毕后,3个阻塞在get方法的线程得到缓存值:load-2
Thread-1并发读缓存=load-2,时间=Thu May 17 17:55:40 CST 2018
Thread-2并发读缓存=load-2,时间=Thu May 17 17:55:40 CST 2018
main==load start==,时间=Thu May 17 17:55:43 CST 2018---主线程访问缓存不存在,执行load()
main==load end==同步耗时2秒重载数据-key=name,value=load-4,时间=Thu May 17 17:55:45 CST 2018---load()完毕!45秒时写入数据:load-4
main休眠5秒后,读缓存=load-4,时间=Thu May 17 17:55:45 CST 2018---主线程得到缓存:load-4
main距离上一次读0.5秒后,读缓存=load-4,时间=Thu May 17 17:55:45 CST 2018--距离上一次写才0.5秒,数据有效:load-4
main==load start==,时间=Thu May 17 17:55:47 CST 2018-47秒时,距离上一次写45秒,超过了1秒,数据无效,再次load()
main==load end==同步耗时2秒重载数据-key=name,value=load-8,时间=Thu May 17 17:55:49 CST 2018--49秒时load()完毕:load-8
main距离上一次读2秒后,读缓存=load-8,时间=Thu May 17 17:55:49 CST 2018--打印get的缓存结果:load-8
2.expireAfterAccess:当 创建 或 写 或 读 之后的 有效期到达时,数据会被自动从缓存中移除
修改测试代码98、99行:
Thread.sleep(700);
System.out.println(Thread.currentThread().getName() + "距离上一次读0.5秒后,读缓存="+cache.get("name")+",时间=" + new Date());
启动-设置缓存,时间=Thu May 17 18:32:38 CST 2018
缓存是否存在=张三
2秒后,缓存是否存在=null
Thread-0...begin,时间=Thu May 17 18:32:40 CST 2018
Thread-1...begin,时间=Thu May 17 18:32:40 CST 2018
Thread-2...begin,时间=Thu May 17 18:32:40 CST 2018
Thread-2==load start==,时间=Thu May 17 18:32:40 CST 2018
Thread-2==load end==同步耗时2秒重载数据-key=name,value=load-6,时间=Thu May 17 18:32:42 CST 2018
Thread-0并发读缓存=load-6,时间=Thu May 17 18:32:42 CST 2018
Thread-1并发读缓存=load-6,时间=Thu May 17 18:32:42 CST 2018
Thread-2并发读缓存=load-6,时间=Thu May 17 18:32:42 CST 2018
main==load start==,时间=Thu May 17 18:32:45 CST 2018
main==load end==同步耗时2秒重载数据-key=name,value=load-7,时间=Thu May 17 18:32:47 CST 2018----47秒时写
main休眠5秒后,读缓存=load-7,时间=Thu May 17 18:32:47 CST 2018
main距离上一次读0.5秒后,读缓存=load-7,时间=Thu May 17 18:32:48 CST 2018---48秒读
main距离上一次读0.5秒后,读缓存=load-7,时间=Thu May 17 18:32:49 CST 2018--49秒距离上一次写47秒,间距大于2秒,但是没有触发load() ,因为48秒时又读了一次,刷新了缓存有效期
3.refreshAfterWrite:当 创建 或 写 之后的 有效期到达时,数据会被自动刷新(注意不是删除是刷新)。
启动-设置缓存,时间=Thu May 17 18:39:59 CST 2018--》59秒写
缓存是否存在=张三
main==reload ==异步重载-key=name,时间=Thu May 17 18:40:01 CST 2018--》01秒,2秒后距离上次写超过1秒,reload异步重载
2秒后,缓存是否存在=张三--》距离上一次写过了2秒,但是会立即返回缓存
Thread-0...begin,时间=Thu May 17 18:40:01 CST 2018--》01秒3个线程并发访问
Thread-1...begin,时间=Thu May 17 18:40:01 CST 2018
Thread-2...begin,时间=Thu May 17 18:40:01 CST 2018
Thread-2并发读缓存=张三,时间=Thu May 17 18:40:01 CST 2018--》01秒3个线程都立即得到了缓存
Thread-0并发读缓存=张三,时间=Thu May 17 18:40:01 CST 2018
Thread-1并发读缓存=张三,时间=Thu May 17 18:40:01 CST 2018
pool-1-thread-1==reload-callable-result=reload-5,时间=Thu May 17 18:40:03 CST 2018--》01秒时的异步,2秒后也就是03秒时,查询结果:reload-5
main==reload ==异步重载-key=name,时间=Thu May 17 18:40:06 CST 2018--》06秒时,距离上一次写时间超过1秒,reload异步重载
main休眠5秒后,读缓存=reload-5,时间=Thu May 17 18:40:06 CST 2018--》06秒时,reload异步重载,立即返回旧值reload-5
main距离上一次读0.5秒后,读缓存=reload-5,时间=Thu May 17 18:40:07 CST 2018
main距离上一次读0.5秒后,读缓存=reload-5,时间=Thu May 17 18:40:07 CST 2018
pool-1-thread-2==reload-callable-result=reload-4,时间=Thu May 17 18:40:08 CST 2018--》06秒时的异步重载,2秒后也就是08秒,查询结果:reload-4
三、源码剖析
前面一节简单演示了google cache的几种用法,本节细看源码。
3.1 简介
我们就从构造器CacheBuilder的源码注释,来看一下google cache的简单介绍:
//LoadingCache加载缓存和缓存实例是以下的特性的组合:
1 A builder of LoadingCache and Cache instances having any combination of the following features:
2 automatic loading of entries into the cache-》把数据实体自动载入到缓存中去-》基本特性
3 least-recently-used eviction when a maximum size is exceeded-》当缓存到达最大数量时回收最少使用的数据-》限制最大内存,避免内存被占满-》高级特性,赞GuavaCache学习笔记三:底层源码阅读的更多相关文章
- Sping学习笔记(一)----Spring源码阅读环境的搭建
idea搭建spring源码阅读环境 安装gradle Github下载Spring源码 新建学习spring源码的项目 idea搭建spring源码阅读环境 安装gradle 在官网中下载gradl ...
- Netty学习笔记(三)——netty源码剖析
1.Netty启动源码剖析 启动类: public class NettyNioServer { public static void main(String[] args) throws Excep ...
- memcached学习笔记——存储命令源码分析上篇
原创文章,转载请标明,谢谢. 上一篇分析过memcached的连接模型,了解memcached是如何高效处理客户端连接,这一篇分析memcached源码中的process_update_command ...
- Laravel学习笔记之Session源码解析(上)
说明:本文主要通过学习Laravel的session源码学习Laravel是如何设计session的,将自己的学习心得分享出来,希望对别人有所帮助.Laravel在web middleware中定义了 ...
- Hadoop学习笔记(10) ——搭建源码学习环境
Hadoop学习笔记(10) ——搭建源码学习环境 上一章中,我们对整个hadoop的目录及源码目录有了一个初步的了解,接下来计划深入学习一下这头神象作品了.但是看代码用什么,难不成gedit?,单步 ...
- 35 网络相关函数(三)——live555源码阅读(四)网络
35 网络相关函数(三)——live555源码阅读(四)网络 35 网络相关函数(三)——live555源码阅读(四)网络 简介 5)NoReuse不重用地址类 6)initializeWinsock ...
- memcached学习笔记——存储命令源码分析下篇
上一篇回顾:<memcached学习笔记——存储命令源码分析上篇>通过分析memcached的存储命令源码的过程,了解了memcached如何解析文本命令和mencached的内存管理机制 ...
- Spring源码阅读笔记01:源码阅读环境准备
1. 写在前面 对于做Java开发的同学来说,Spring就像是一条绕不过去的路,但是大多数也只是停留在对Spring的简单使用层面上,对于其背后的原理所知不多也不愿深究,关于这个问题,我在平时的生活 ...
- 16 BasicHashTable基本哈希表类(三)——Live555源码阅读(一)基本组件类
这是Live555源码阅读的第一部分,包括了时间类,延时队列类,处理程序描述类,哈希表类这四个大类. 本文由乌合之众 lym瞎编,欢迎转载 http://www.cnblogs.com/oloroso ...
随机推荐
- 我们是怎么做Code Review的
前几天看了<Code Review 程序员的寄望与哀伤>,想到我们团队开展Code Review也有2年了,结果还算比较满意,有些经验应该可以和大家一起分享.探讨.我们为什么要推行Code ...
- OC编程之道-创建对象之工厂方法
一 何为工厂方法模式?(what) 定义创建对象的接口,让子类决定实例化哪一个类,工厂方法是的一个类的实例化延迟到其子类. 工厂方法创建的对象拥有一组共同的行为,所以往类层次结构中引入新的具体产品并不 ...
- zend studio 的注册码-php的编辑器
zend studio 12.5 patch包: 组织名(倒过来写)+ jar包名称 : com.zend.verifier-xxx.jar 将破解包中的jar包 覆盖原来就有的那个 verifier ...
- 如何配置virtualBox端口转发
1,第一步登陆虚拟主机,安装openssh-server(这一步非常重要,如果不安装,你在宿主机上怎么链接都是连不上的,我当时就犯了这个错误) apt-get install openssh-serv ...
- HTC Vive开发笔记之SteamVR插件集成
重要组件 SteamVR_Camera VR摄像机,主要功能是将Unity摄像机的画面进行变化,形成Vive中的成像画面 使用方法: l 在任一个摄像机上增加脚本 l 点击Expand按钮 完成以上操 ...
- 1486: [HNOI2009]最小圈 - BZOJ
在机房的小伙伴提醒是二分之后,我想到了是判负环,所以我用spfa,而且我保持dis都是小于等于0,本以为这样就能过了,可是还是有一个点达到了3.8s左右(其他都是0.0几秒) 所以还是写了dfs版 ...
- [51 nod]1009 数字1的数量
1009 数字1的数量 基准时间限制:1 秒 空间限制:131072 KB 分值: 5 难度:1级算法题 给定一个十进制正整数N,写下从1开始,到N的所有正数,计算出其中出现所有1的个数. 例如: ...
- [ACdream]女神教你字符串——导字符串
Problem Description 正如大家知道的,女神喜欢字符串,而在字符串中,女神最喜欢回文字符串,但是不是所有的字符串都是回文字符串,但是有一些字符串可以进行“求导”来变成回文字符串. 字符 ...
- 数据结构 单链表&;顺序表
顺序表: 一般使用数组(C语言中的数组采用顺序存储方式.即连续地址存储)来描述. 优点:在于随机访问元素, 缺点:插入和和删除的时候,需要移动大量的元素. 链表: 优点:插入或删除元素时很方便,使用灵 ...
- 数据库SQL语言学习----左外连接,右外连接,外连接,自然连接的形象对比
现在有两张表,一张Student 另一张Score 1.查询每个学生及其选修课程的情况: 自然连接,Sno在Cscore中找不到就不显示,Cno在Cscore中找不到也不显示 SELECT Stu ...