提交 2d52516f authored 作者: Thomas Mueller's avatar Thomas Mueller

LIRS replacement algorithm

上级 dbce1ba0
/*
* Copyright 2004-2011 H2 Group. Multiple-Licensed under the H2 License, Version
* 1.0, and under the Eclipse Public License, Version 1.0
* (http://h2database.com/html/license.html). Initial Developer: H2 Group
*/
package org.h2.test.store;
import java.util.HashMap;
import java.util.Random;
import org.h2.dev.store.btree.CacheLirs;
import org.h2.test.TestBase;
import org.h2.util.New;
/**
* Tests the cache algorithm.
*/
public class TestCache extends TestBase {
/**
* Run just this test.
*
* @param a ignored
*/
public static void main(String... a) throws Exception {
TestBase.createCaller().init().test();
}
public void test() throws Exception {
testEdgeCases();
testGetPutPeekRemove();
testLimitHot();
testLimitNonResident();
testBadHashMethod();
testScanResistance();
testRandomOperations();
}
private void testEdgeCases() {
CacheLirs<Integer, Integer> test = CacheLirs.newInstance(0);
test.put(1, 10);
assertEquals(10, test.get(1).intValue());
}
private void testGetPutPeekRemove() {
CacheLirs<Integer, Integer> test = CacheLirs.newInstance(4);
test.put(1, 10);
test.put(2, 20);
test.put(3, 30);
assertNull(test.peek(4));
assertNull(test.get(4));
test.put(4, 40);
assertEquals("mem: 4 stack: 4 3 2 1 cold: non-resident:", toString(test));
// move middle to front
assertEquals(30, test.get(3).intValue());
assertEquals(20, test.get(2).intValue());
assertEquals(20, test.peek(2).intValue());
// already on (an optimization)
assertEquals(20, test.get(2).intValue());
assertEquals(10, test.peek(1).intValue());
assertEquals(10, test.get(1).intValue());
assertEquals("mem: 4 stack: 1 2 3 4 cold: non-resident:", toString(test));
test.put(3, 30);
assertEquals("mem: 4 stack: 3 1 2 4 cold: non-resident:", toString(test));
// 5 is cold; will make 4 non-resident
test.put(5, 50);
assertEquals("mem: 4 stack: 5 3 1 2 cold: 5 non-resident: 4", toString(test));
assertNull(test.peek(4));
assertNull(test.get(4));
assertEquals(10, test.get(1).intValue());
assertEquals(20, test.get(2).intValue());
assertEquals(30, test.get(3).intValue());
assertEquals("mem: 4 stack: 3 2 1 cold: 5 non-resident: 4", toString(test));
assertEquals(50, test.get(5).intValue());
assertEquals("mem: 4 stack: 5 3 2 1 cold: 5 non-resident: 4", toString(test));
assertEquals(50, test.get(5).intValue());
assertEquals("mem: 4 stack: 5 3 2 cold: 1 non-resident: 4", toString(test));
// remove
assertTrue(test.remove(5));
assertFalse(test.remove(5));
assertEquals("mem: 3 stack: 3 2 1 cold: non-resident: 4", toString(test));
assertTrue(test.remove(4));
assertFalse(test.remove(4));
assertEquals("mem: 3 stack: 3 2 1 cold: non-resident:", toString(test));
test.put(4, 40);
test.put(5, 50);
assertEquals("mem: 4 stack: 5 4 3 2 cold: 5 non-resident: 1", toString(test));
test.get(5);
test.get(2);
test.get(3);
test.get(4);
assertEquals("mem: 4 stack: 4 3 2 5 cold: 2 non-resident: 1", toString(test));
assertTrue(test.remove(5));
assertEquals("mem: 3 stack: 4 3 2 cold: non-resident: 1", toString(test));
assertTrue(test.remove(2));
assertTrue(test.remove(1));
assertEquals("mem: 2 stack: 4 3 cold: non-resident:", toString(test));
test.put(1, 10);
test.put(2, 20);
assertEquals("mem: 4 stack: 2 1 4 3 cold: non-resident:", toString(test));
test.get(1);
test.get(3);
test.get(4);
assertEquals("mem: 4 stack: 4 3 1 2 cold: non-resident:", toString(test));
assertTrue(test.remove(1));
assertEquals("mem: 3 stack: 4 3 2 cold: non-resident:", toString(test));
test.remove(2);
test.remove(3);
test.remove(4);
// test clear
test.clear();
assertEquals("mem: 0 stack: cold: non-resident:", toString(test));
}
private void testLimitHot() {
CacheLirs<Integer, Integer> test = CacheLirs.newInstance(100);
for (int i = 0; i < 300; i++) {
test.put(i, 10 * i);
}
assertEquals(199, test.getSize());
assertEquals(93, test.getHotSize());
assertEquals(99, test.getNonResidentSize());
}
private void testLimitNonResident() {
CacheLirs<Integer, Integer> test = CacheLirs.newInstance(4);
for (int i = 0; i < 20; i++) {
test.put(i, 10 * i);
}
assertEquals("mem: 4 stack: 19 18 17 16 3 2 1 cold: 19 non-resident: 18 17 16", toString(test));
}
private void testBadHashMethod() {
// ensure an 2^n cache size
final int size = 4;
/**
* A class with a bad hashCode implementation.
*/
class BadHash {
int x;
BadHash(int x) {
this.x = x;
}
public int hashCode() {
return (x & 1) * size * 2;
}
public boolean equals(Object o) {
return ((BadHash) o).x == x;
}
public String toString() {
return "" + x;
}
}
CacheLirs<BadHash, Integer> test = CacheLirs.newInstance(size * 2);
for (int i = 0; i < size; i++) {
test.put(new BadHash(i), i);
}
for (int i = 0; i < size; i++) {
if (i % 3 == 0) {
assertTrue(test.remove(new BadHash(i)));
assertFalse(test.remove(new BadHash(i)));
}
}
for (int i = 0; i < size; i++) {
if (i % 3 == 0) {
assertNull(test.get(new BadHash(i)));
} else {
assertEquals(i, test.get(new BadHash(i)).intValue());
}
}
for (int i = 0; i < size; i++) {
test.put(new BadHash(i), i);
}
for (int i = 0; i < size; i++) {
if (i % 3 == 0) {
assertTrue(test.remove(new BadHash(i)));
assertFalse(test.remove(new BadHash(i)));
}
}
for (int i = 0; i < size; i++) {
if (i % 3 == 0) {
assertNull(test.get(new BadHash(i)));
} else {
assertEquals(i, test.get(new BadHash(i)).intValue());
}
}
}
private void testScanResistance() {
boolean log = false;
int size = 20;
// cache size 11 (10 hot, 1 cold)
CacheLirs<Integer, Integer> test = CacheLirs.newInstance(size / 2 + 1);
// init the cache with some dummy entries
for (int i = 0; i < size; i++) {
test.put(-i, -i * 10);
}
// init with 0..9, ensure those are hot entries
for (int i = 0; i < size / 2; i++) {
test.put(i, i * 10);
test.get(i);
if (log) {
System.out.println("get " + i + " -> " + test);
}
}
// read 0..9, add 10..19 (cold)
for (int i = 0; i < size; i++) {
Integer x = test.get(i);
Integer y = test.peek(i);
if (i < size / 2) {
assertTrue("i: " + i, x != null);
assertTrue("i: " + i, y != null);
assertEquals(i * 10, x.intValue());
assertEquals(i * 10, y.intValue());
} else {
assertNull(x);
assertNull(y);
test.put(i, i * 10);
// peek should have no effect
assertEquals(i * 10, test.peek(i).intValue());
}
if (log) {
System.out.println("get " + i + " -> " + test);
}
}
// ensure 0..9 are hot, 10..18 are not resident, 19 is cold
for (int i = 0; i < size; i++) {
Integer x = test.get(i);
if (i < size / 2 || i == size - 1) {
assertTrue("i: " + i, x != null);
assertEquals(i * 10, x.intValue());
} else {
assertNull(x);
}
}
}
private void testRandomOperations() {
boolean log = false;
int size = 10;
Random r = new Random(1);
for (int j = 0; j < 100; j++) {
CacheLirs<Integer, Integer> test = CacheLirs.newInstance(size / 2);
HashMap<Integer, Integer> good = New.hashMap();
for (int i = 0; i < 10000; i++) {
int key = r.nextInt(size);
int value = r.nextInt();
switch (r.nextInt(3)) {
case 0:
if (log) {
System.out.println(i + " put " + key + " " + value);
}
good.put(key, value);
test.put(key, value);
break;
case 1:
if (log) {
System.out.println(i + " get " + key);
}
Integer a = good.get(key);
Integer b = test.get(key);
if (a == null) {
assertNull(b);
} else if (b != null) {
assertEquals(a, b);
}
break;
case 2:
if (log) {
System.out.println(i + " remove " + key);
}
good.remove(key);
test.remove(key);
break;
}
if (log) {
System.out.println(" -> " + toString(test));
}
}
}
}
private static <K, V> String toString(CacheLirs<K, V> cache) {
StringBuilder buff = new StringBuilder();
buff.append("mem: " + cache.getUsedMemory());
buff.append(" stack:");
for (K k : cache.keys(false, false)) {
buff.append(' ').append(k);
}
buff.append(" cold:");
for (K k : cache.keys(true, false)) {
buff.append(' ').append(k);
}
buff.append(" non-resident:");
for (K k : cache.keys(true, true)) {
buff.append(' ').append(k);
}
return buff.toString();
}
}
...@@ -6,27 +6,51 @@ ...@@ -6,27 +6,51 @@
*/ */
package org.h2.dev.store.btree; package org.h2.dev.store.btree;
import org.h2.util.MathUtils; import java.util.ArrayList;
import java.util.List;
/** /**
* An implementation of the LIRS replacement algorithm from * A cache.
* Xiaodong Zhang and Song Jiang as described in * <p>
* http://www.cse.ohio-state.edu/~zhang/lirs-sigmetrics-02.html * This implementation is not multi-threading save.
* This algorithm is scan resistant. *
* It is important to use a good hash function for the key (there is no guard against bad hash functions).
* <p>
* An implementation of the LIRS replacement algorithm from Xiaodong Zhang and
* Song Jiang as described in
* http://www.cse.ohio-state.edu/~zhang/lirs-sigmetrics-02.html with a few
* smaller changes: An additional queue for non-resident entries is used, to
* prevent unbound memory usage. The maximum size of this queue is at most the
* size of the rest of the stack. This implementation allows each entry to have
* a distinct memory size. At most 6.25% of the mapped entries are cold.
* *
* @param <K> the key type * @param <K> the key type
* @param <V> the value type * @param <V> the value type
*/ */
public class CacheLirs<K, V> { public class CacheLirs<K, V> {
private long maxMemory = 100; private long maxMemory;
private long maxMemoryHot;
private long currentMemory; private long currentMemory;
private long currentMemoryHot; private int averageMemory;
private int averageMemory = 1; private int mapSize, stackSize, queueSize, queue2Size;
private Entry<K, V>[] entries; private Entry<K, V>[] entries;
private int mask; private int mask;
private Entry<K, V> head;
/**
* The stack of recently referenced elements. This includes all hot entries,
* the recently referenced cold entries, and all non-resident cold entries.
*/
private Entry<K, V> stack;
/**
* The queue of resident cold entries.
*/
private Entry<K, V> queue;
/**
* The queue of non-resident cold entries.
*/
private Entry<K, V> queue2;
/** /**
* Create a new cache. * Create a new cache.
...@@ -34,68 +58,86 @@ public class CacheLirs<K, V> { ...@@ -34,68 +58,86 @@ public class CacheLirs<K, V> {
* @param maxMemory the maximum memory to use * @param maxMemory the maximum memory to use
* @param averageMemory the average memory usage of an object * @param averageMemory the average memory usage of an object
*/ */
public CacheLirs(long maxMemory, int averageMemory) { private CacheLirs(long maxMemory, int averageMemory) {
this.maxMemory = maxMemory; this.maxMemory = maxMemory;
this.averageMemory = averageMemory; this.averageMemory = averageMemory;
clear(); clear();
} }
/**
* Create a new cache.
*
* @param size the maximum number of elements
*/
public static <K, V> CacheLirs<K, V> newInstance(int size) {
return new CacheLirs<K, V>(size, 1);
}
/** /**
* Clear the cache. * Clear the cache.
*/ */
public void clear() { public void clear() {
int len = MathUtils.convertLongToInt(maxMemory / averageMemory); long maxLen = (long) (maxMemory / averageMemory / 0.75);
len = MathUtils.nextPowerOf2(len); long l = 8;
maxMemoryHot = maxMemory * 98 / 100; while (l < maxLen) {
maxMemoryHot = Math.min(maxMemoryHot, maxMemory - averageMemory); l += l;
}
int len = (int) Math.min(1L << 31, l);
mask = len - 1; mask = len - 1;
head = new Entry<K, V>(); stack = new Entry<K, V>();
head.stackPrev = head.stackNext = head; stack.stackPrev = stack.stackNext = stack;
head.queuePrev = head.queueNext = head; queue = new Entry<K, V>();
queue.queuePrev = queue.queueNext = queue;
queue2 = new Entry<K, V>();
queue2.queuePrev = queue2.queueNext = queue2;
// first set to null - avoiding out of memory // first set to null - avoiding out of memory
entries = null; entries = null;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Entry<K, V>[] e = new Entry[len]; Entry<K, V>[] e = new Entry[len];
entries = e; entries = e;
currentMemory = currentMemoryHot = 0; currentMemory = 0;
stackSize = queueSize = queue2Size = 0;
} }
/** /**
* Get an entry if the entry is cached. * Get an entry if the entry is cached. This method does not modify the
* internal state.
*
* @param key the key
* @return the value, or null if not found
*/
public V peek(K key) {
Entry<K, V> e = find(key);
return e == null ? null : e.value;
}
/**
* Get an entry if the entry is cached. This method adjusts the internal
* state of the cache, to ensure commonly used entries stay in the cache.
* *
* @param key the key * @param key the key
* @return the value, or null if not found * @return the value, or null if not found
*/ */
public V get(K key) { public V get(K key) {
Entry<K, V> e = find(key); Entry<K, V> e = find(key);
if (e == null) { if (e == null || e.value == null) {
return null; return null;
} else if (e.hot) { } else if (e.isHot()) {
if (e == head.stackNext) { if (e != stack.stackNext) {
// already the first element boolean wasEnd = e == stack.stackPrev;
} else { removeFromStack(e);
boolean wasLast = e == head.stackPrev; if (wasEnd) {
e.removeFromStack();
if (wasLast) {
pruneStack(); pruneStack();
} }
addToStack(e); addToStack(e);
} }
} else { } else {
if (e.stackPrev != null) { removeFromQueue(e);
e.removeFromStack(); if (e.stackNext != null) {
e.hot = true; removeFromStack(e);
currentMemoryHot += e.memory; convertOldestHotToCold();
e.removeFromQueue();
Entry<K, V> last = head.stackPrev;
last.removeFromStack();
last.hot = false;
currentMemoryHot -= last.memory;
addToQueue(last);
pruneStack();
} else { } else {
e.removeFromQueue(); addToQueue(queue, e);
addToQueue(e);
} }
addToStack(e); addToStack(e);
} }
...@@ -103,52 +145,41 @@ public class CacheLirs<K, V> { ...@@ -103,52 +145,41 @@ public class CacheLirs<K, V> {
} }
/** /**
* Add an entry to the cache. This will assume a memory usage of 1. * Add an entry to the cache. This method is the same as adding an entry
* with the average memory size.
* *
* @param key the key * @param key the key
* @param value the value * @param value the value
*/ */
void put(K key, V value) { public void put(K key, V value) {
put(key, value, 1); put(key, value, averageMemory);
} }
/** /**
* Add an entry to the cache. * Add an entry to the cache. The entry may or may not exist in the cache
* yet. This method will usually mark unknown entries as cold and known
* entries as hot.
* *
* @param key the key * @param key the key
* @param value the value * @param value the value
* @param memory the memory used for the given entry * @param memory the memory used for the given entry
*/ */
void put(K key, V value, int memory) { public void put(K key, V value, int memory) {
Entry<K, V> e = find(key); if (find(key) != null) {
if (e != null) {
if (currentMemory + memory > maxMemoryHot) {
if (head != head.queueNext) {
remove(head.queueNext.key);
}
}
remove(key); remove(key);
} }
e = new Entry<K, V>(); Entry<K, V> e = new Entry<K, V>();
e.key = key; e.key = key;
int hash = key.hashCode();
// all pages are hot until the memory limit is reached
e.hot = currentMemory + memory <= maxMemoryHot;
e.hashCode = hash;
e.value = value; e.value = value;
e.memory = memory; e.memory = memory;
int index = hash & mask; int index = key.hashCode() & mask;
e.chained = entries[index]; e.chained = entries[index];
entries[index] = e; entries[index] = e;
currentMemory += memory; currentMemory += memory;
if (e.hot) { if (currentMemory > maxMemory && mapSize > 0) {
currentMemoryHot += memory; evict(e);
} else {
if (currentMemory > maxMemory) {
removeOld();
}
addToQueue(e);
} }
mapSize++;
addToStack(e); addToStack(e);
} }
...@@ -156,7 +187,7 @@ public class CacheLirs<K, V> { ...@@ -156,7 +187,7 @@ public class CacheLirs<K, V> {
* Remove an entry. * Remove an entry.
* *
* @param key the key * @param key the key
* @return true if the entry was found * @return true if the entry was found (resident or non-resident)
*/ */
public boolean remove(K key) { public boolean remove(K key) {
int hash = key.hashCode(); int hash = key.hashCode();
...@@ -165,7 +196,7 @@ public class CacheLirs<K, V> { ...@@ -165,7 +196,7 @@ public class CacheLirs<K, V> {
if (e == null) { if (e == null) {
return false; return false;
} }
if (e.hashCode == hash && e.key.equals(key)) { if (e.key.equals(key)) {
entries[index] = e.chained; entries[index] = e.chained;
} else { } else {
Entry<K, V> last; Entry<K, V> last;
...@@ -175,74 +206,187 @@ public class CacheLirs<K, V> { ...@@ -175,74 +206,187 @@ public class CacheLirs<K, V> {
if (e == null) { if (e == null) {
return false; return false;
} }
} while (e.hashCode != hash || !e.key.equals(key)); } while (!e.key.equals(key));
last.chained = e.chained; last.chained = e.chained;
} }
mapSize--;
currentMemory -= e.memory; currentMemory -= e.memory;
if (e.stackNext != null) { if (e.stackNext != null) {
e.removeFromStack(); removeFromStack(e);
} }
if (e.queueNext != null) { if (e.isHot()) {
e.removeFromQueue(); // when removing a hot entry, convert the newest cold entry to hot,
} // so that we keep the number of hot entries
if (e.hot) { e = queue.queueNext;
e = head.queueNext; if (e != queue) {
if (e != head) { removeFromQueue(e);
e.removeFromQueue();
e.hot = true;
if (e.stackNext == null) { if (e.stackNext == null) {
// add to bottom of the stack addToStackBottom(e);
e.stackNext = head;
e.stackPrev = head.stackPrev;
e.stackPrev.stackNext = e;
head.stackPrev = e;
} }
} }
} else {
removeFromQueue(e);
} }
pruneStack();
return true; return true;
} }
private void pruneStack() { private void evict(Entry<K, V> newCold) {
while (true) { while ((queueSize << 5) < mapSize) {
Entry<K, V> last = head.stackPrev; convertOldestHotToCold();
if (last == head || last.hot) { }
break; addToQueue(queue, newCold);
while (currentMemory > maxMemory) {
Entry<K, V> e = queue.queuePrev;
currentMemory -= e.memory;
removeFromQueue(e);
e.value = null;
e.memory = 0;
addToQueue(queue2, e);
while (queue2Size + queue2Size > stackSize) {
e = queue2.queuePrev;
remove(e.key);
} }
last.removeFromStack();
} }
} }
private void removeOld() { private void convertOldestHotToCold() {
while (currentMemory > maxMemory) { Entry<K, V> last = stack.stackPrev;
remove(head.queuePrev.key); removeFromStack(last);
addToQueue(queue, last);
pruneStack();
}
private void pruneStack() {
while (true) {
Entry<K, V> last = stack.stackPrev;
if (last == stack || last.isHot()) {
break;
}
removeFromStack(last);
} }
} }
private Entry<K, V> find(K key) { private Entry<K, V> find(K key) {
int hash = key.hashCode(); int hash = key.hashCode();
Entry<K, V> e = entries[hash & mask]; Entry<K, V> e = entries[hash & mask];
while (e != null && (e.hashCode != hash || !e.key.equals(key))) { while (e != null && !e.key.equals(key)) {
e = e.chained; e = e.chained;
} }
return e; return e;
} }
private void addToQueue(Entry<K, V> e) { private void addToStack(Entry<K, V> e) {
e.queuePrev = head; e.stackPrev = stack;
e.queueNext = head.queueNext; e.stackNext = stack.stackNext;
e.stackNext.stackPrev = e;
stack.stackNext = e;
stackSize++;
}
private void addToStackBottom(Entry<K, V> e) {
e.stackNext = stack;
e.stackPrev = stack.stackPrev;
e.stackPrev.stackNext = e;
stack.stackPrev = e;
stackSize++;
}
private void removeFromStack(Entry<K, V> e) {
e.stackPrev.stackNext = e.stackNext;
e.stackNext.stackPrev = e.stackPrev;
e.stackPrev = e.stackNext = null;
stackSize--;
}
private void addToQueue(Entry<K, V> q, Entry<K, V> e) {
e.queuePrev = q;
e.queueNext = q.queueNext;
e.queueNext.queuePrev = e; e.queueNext.queuePrev = e;
head.queueNext = e; q.queueNext = e;
if (e.value != null) {
queueSize++;
} else {
queue2Size++;
}
} }
private void addToStack(Entry<K, V> e) { private void removeFromQueue(Entry<K, V> e) {
e.stackPrev = head; e.queuePrev.queueNext = e.queueNext;
e.stackNext = head.stackNext; e.queueNext.queuePrev = e.queuePrev;
e.stackNext.stackPrev = e; e.queuePrev = e.queueNext = null;
head.stackNext = e; if (e.value != null) {
queueSize--;
} else {
queue2Size--;
}
}
/**
* Get the number of mapped entries (resident and non-resident).
*
* @return the number of entries
*/
public int getSize() {
return mapSize;
}
/**
* Get the number of hot entries.
*
* @return the number of entries
*/
public int getHotSize() {
return mapSize - queueSize - queue2Size;
} }
/** /**
* A cache entry. * Get the number of non-resident entries.
*
* @return the number of entries
*/
public int getNonResidentSize() {
return queue2Size;
}
/**
* Get the list of keys for this map. This method allows to view the internal
* state of the cache.
*
* @param cold if true only the keys for the cold entries are returned
* @param nonResident true for non-resident entries
* @return the key set
*/
public List<K> keys(boolean cold, boolean nonResident) {
ArrayList<K> s = new ArrayList<K>();
if (cold) {
Entry<K, V> start = nonResident ? queue2 : queue;
for (Entry<K, V> e = start.queueNext; e != start; e = e.queueNext) {
s.add(e.key);
}
} else {
for (Entry<K, V> e = stack.stackNext; e != stack; e = e.stackNext) {
s.add(e.key);
}
}
return s;
}
/**
* Get the currently used memory.
*
* @return the used memory
*/
public long getUsedMemory() {
return currentMemory;
}
/**
* A cache entry. Each entry is either hot (low inter-reference recency;
* lir), cold (high inter-reference recency; hir), or non-resident-cold. Hot
* entries are in the stack only. Cold entries are in the queue, and may be
* in the stack. Non-resident-cold entries have their value set to null and
* are in the stack and in the non-resident queue.
* *
* @param <K> the key type * @param <K> the key type
* @param <V> the value type * @param <V> the value type
...@@ -250,21 +394,13 @@ public class CacheLirs<K, V> { ...@@ -250,21 +394,13 @@ public class CacheLirs<K, V> {
static class Entry<K, V> { static class Entry<K, V> {
K key; K key;
V value; V value;
int hashCode;
int memory; int memory;
boolean hot; Entry<K, V> stackPrev, stackNext;
Entry<K, V> stackPrev, stackNext, queuePrev, queueNext, chained; Entry<K, V> queuePrev, queueNext;
Entry<K, V> chained;
void removeFromStack() {
stackPrev.stackNext = stackNext;
stackNext.stackPrev = stackPrev;
stackPrev = stackNext = null;
}
void removeFromQueue() { boolean isHot() {
queuePrev.queueNext = queueNext; return queueNext == null;
queueNext.queuePrev = queuePrev;
queuePrev = queueNext = null;
} }
} }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论