类集设置的目的
普通的对象数组的最大问题在于数组中的元素个数是固定的,不能动态的扩充大小,最早的时候可以通过链表实现一个动态对象数组。但是这样做太复杂,所以在 Java 中为了方便用户操作各个数据结构, 所以引入了类集的概念,可以把类集称为 java 对数据结构的实现。 在整个类集中的,这个概念是从 JDK 1.2(Java 2)之后才正式引入的,最早也提供了很多的操作类,但是并没有完 整的提出类集的完整概念。 类集中最大的几个操作接口:Collection、Map、Iterator,这三个接口为以后要使用的最重点的接口。 所有的类集操作的接口或类都在 java.util 包中。
Java 类集结构图
Iterable 接口
此接口位于java.lang包下,实现此接口允许对象成为for-each循环的目标,也就是增强for循环,它是java中的一种语法糖
List<Object> list=new ArrayList();
for(Object obj:list){}
除了实现此接口的对象外,数组也可以用for-each循环遍历
Collection 接口
Collection 接口是在整个 Java 类集中保存单值的最大操作父接口,里面每次操作的时候都只能保存一个对象的数据。 此接口定义在 java.util 包中。
public interface Collection<E> extends Iterable<E>
此接口使用了泛型技术,在 JDK 1.5 之后为了使类集操作的更加安全,所以引入了泛型。
常用方法
此接口的全部子类或子接口就将全部继承以上接口中的方法。 但是,在开发中不会直接使用 Collection 接口。而使用其操作的子接口:List、Set。
List 接口
在整个集合中 List 是 Collection 的子接口,里面的所有数据都允许重复。
public interface List<E> extends Collection<E>
此接口上依然使用了泛型技术。此接口对于 Collection 接口来讲有如下的扩充方法:
常用的实现类: · ArrayList(95%)、Vector(4%)、LinkedList(1%)
ArrayList
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, Serializable
此类继承了 AbstractList 类。AbstractList 是 List 接口的子类。AbstractList 是个抽象类,采用适配器设计模式。 对于增删操作慢,对于查找操作快。
ArrayList() 构造一个初始容量为0的空列表。(初次add会扩容至长度10)
ArrayList(int initialCapacity) 构造具有指定初始容量的空列表。
ArrayList(Collection<? extends E> c) 按照集合的迭代器返回的顺序构造一个包含指定集合元素的列表。
因此若要存入大量数据,可以使用参数为int的参数指定初始数组的长度,节省内存开销
List<String> all = new ArrayList<String>(); // 实例化List对象,并指定泛型类型
all.add("hello "); // 增加内容,此方法从Collection接口继承而来
all.add(0, "LAMP ");// 增加内容,此方法是List接口单独定义的
ArrayList不是线程安全的容器,如果多个线程中至少有两个线程修改了ArrayList的结构的话就会
导致线程安全问题,作为替代条件可以使用线程安全的List,应使用Collections.synchronizedList
List list = Collections.synchronizedList(new ArrayList(...))
ArrayList具有fail-fast快速失败机制,能够对ArrayList作出失败检测。当在迭代集合的过程中该
集合在结构上发生改变的时候,就有可能会发生fail-fast,即抛出ConcurrentModificationException异常。
Vector
与 ArrayList 一样,Vector 本身也属于 List 接口的子类,此类的定义如下:
public class Vector<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, Serializable
使用Vector存储数据并对其增删改查的操作结果与使用 ArrayList 本身并没有任何的区别(增加了对数组动态扩容增量的设置)。因为操作的时候是以接口为操作的标准。 但是 Vector 属于 Java 元老级的操作类,是最早的提供了动态对象数组的操作类,在 JDK 1.0 的时候就已经推出了此 类的使用,只是后来在 JDK 1.2 之后引入了 Java 类集合框架。但是为了照顾很多已经习惯于使用 Vector的用户,所以在 JDK 1.2 之后将 Vector 类进行了升级了,让其多实现了一个 List 接口,如此这个类继续保留了下来(不常用)。
Vector 类和 ArrayList 类的区别
ArrayList线程不安全,性能较高,Vector线程安全,性能较低
Vector每次动态扩容 请求其大 小的双倍空间,而 ArrayList 每次对 size 增长 50%。
LinkedList
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, Serializable
LinkedList继承了 AbstractList,所以是 List 的子类。增加删除快,查找慢,但是同时也是Deque 接口的子类(Deque是Queue的子接口)
Queue<String> queue = new LinkedList<String>();
queue.add("A");
queue.add("B");
queue.add("C");
int len=queue.size();//把queue的大小先取出来,否则每循环一次,移除一个元素,就少
一个元素,那么queue.size()在变小,就不能循环queue.size()次了。
for (int x = 0; x <len; x++) {
System.out.println(queue.poll());
}
System.out.println(queue);
LinkedList是一个双向链表,允许存储任何元素(包括null)。
LinkedList所有的操作都可以表现为双向性的,索引到链表的操作将遍历从头到尾,视哪个距离近为遍历顺序。
LinkedList也不是线程安全的,如果多个线程并发访问链表,并且至少其中的一个线程修改了链表的结构,那么这个链表必须进行外部加锁。或者使用
List list = Collections.synchronizedList(new LinkedList(...))
Iterator(重点)
Iterator 属于迭代输出,基本的操作原理是不断的判断是否有下一个元素,有的话,则直接输出。
public interface Iterator<E>
要想使用此接口,则必须使用 Collection 接口,因为在 Collection 接口中定义了一个 iterator()方法,可以用于为 Iterator 接口进行实例化操作。
此接口规定了以下的三个方法:
通过 Collection 接口为其进行实例化之后,一定要记住,Iterator 中的操作指针是在第一条元素之上,当调用 next()方 法的时候,获取当前指针指向的值并向下移动,使用 hasNext()可以检查序列中是否还有元素。
Collection<String> all = new ArrayList<String>();
all.add("A");
all.add("B");
all.add("C");
Iterator<String> iter = all.iterator();
while (iter.hasNext()) {// 判断是否有下一个元素
String str = iter.next(); // 取出当前元素
System.out.print(str + "、");
}
for(Iterator it = coll.iterator(); it.hasNext(); ){
System.out.println(it.next());
}
特别注意:
以上的操作是 Iterator 接口使用最多的形式,也是一个标准的输出形式。 但是在使用 Iterator 输出的时候有一点必须注意,在进行迭代输出的时候如果要想删除当前元素,则只能使用 Iterator 接口中的 remove()方法,而不能使用集合中的 remove()方法。否则将出现未知的错误(快速失败)。
Iterator<String> iter = all.iterator();
while (iter.hasNext()) {// 判断是否有下一个元素
String str = iter.next(); // 取出当前元素
if (str.equals("C")) {
all.remove(str); // 错误,调用了集合中的删除,原本的要输出的集合的内容被破坏掉了
此类的iterator方法返回的迭代器是快速失败的 :如果在创建迭代器之后的任何时间修改集合,除了通过迭代器自己的remove方法之外,迭代器将抛出ConcurrentModificationException
与之相对的是安全失败,是虽然集合被修改,不会造成影响
} else {
System.out.print(str + "、");
}
}
while (iter.hasNext()) {// 判断是否有下一个元素
String str = iter.next(); // 取出当前元素
if (str.equals("C")) {
iter.remove(); //正确
} else {
System.out.print(str + "、");
}
}
注意:只有获取(调用next方法)remove才能正确删除,因为next会将指针下移。
但是,从实际的开发角度看,元素的删除操作很少出现,即:集合中很少有删除元素 的操作。 Iterator 接口本身可以完成输出的功能,但是此接口只能进行由前向后的单向输出。如果要想进行双向输出,则必须 使用其子接口 ListIterator。
ListIterator
ListIterator 是 Iterator 的子接口,可以进行双向输出的迭代接口,定义如下:
public interface ListIterator<E>
extends Iterator<E>
但是如果要想使用 ListIterator 接口,则必须依靠 List 接口进行实例化,因为List 接口中定义了方法:ListIterator listIterator()
ArrayList<Integer>arrayList=new ArrayList<>();
ListIterator<Integer> integerListIterator = arrayList.listIterator();
integerListIterator.add(1);//在当前指针前添加
System.out.println(integerListIterator.previous());//获取当前指针前的数据并将指针前移
integerListIterator.set(2);//将当前指针下一个数据设置为2
System.out.println(integerListIterator.next());//获取当前指针后的数据并将指针后移
Set 接口
Set 接口也是 Collection 的子接口,与 List 接口最大的不同在于,Set 接口里面的内容是不允许重复(包括null)的。 Set 接口没有对 Collection 接口进行扩充,基本上还是与 Collection 接口保持一致。因为此接口没有 List 接口中定义 的 get(int index)方法,所以无法使用循环进行输出。但是也能通过循环的方式将 Set 接口中的内容输出,因为在 Collection 接口中定义了将集合变为对象数组的方法(toarray)或者得到其元素的迭代器(iterator))
在此接口中有两个常用的子类:HashSet、TreeSet
散列存放:HashSet
HashSet 属于散列的存放类集,里面的内容是无序存放(且不重复)的。内部存储了一个HashMap对象(将数据存储在HashMap中),由于Map的键是不可重复的,相当于使用Map的键存储数据。它不能保证集合的迭代顺序。这个类允许null元素。
使用 HashSet 实例化的 Set 接口实例,本身属于无序的存放。
Set<String> all = new HashSet<String>();
all.add("h");
all.add("h");
all.add("a");
System.out.println(all); //[a, h]
Object obj[] = all.toArray(); // 将集合变为对象数组
for (int x = 0; x < obj.length; x++) {
System.out.print(obj[x] + "、");
}
但是,以上的操作不好,因为在操作的时候已经指定了操作的泛型类型,最好的做法是由泛型所指定的类型变为指定的数组。 所以只能使用以下的方法: T[] toArray(T[] a)
String[] str = all.toArray(new String[] {});// 变为指定的泛型类型数组
for (int x = 0; x < str.length; x++) {
System.out.print(str[x] + "、");
}
Iterator<String> iterator = all.iterator();
while (iterator.hasNext()){
String next = iterator.next();
System.out.println(next);
}
for (String s:all){
System.out.println(s);
}
如果将可变对象用作set元素,则必须非常小心。 如果在对象是集合中的元素时以影响equals
比较的方式更改对象的值,则不指定集合的行为。
注意这个实现不是线程安全的。如果多线程并发访问HashSet,并且至少一个线程修改了set,必
须进行外部加锁。或者使用Collections.synchronizedSet()方法重写。
SortedSet接口
直接继承于Set接口,使用Comparable对元素进行自然排序或者使用Comparator在创建时对元素提供定制的排序规则。set的迭代器将按升序元素顺序遍历集合。
排序的子类:TreeSet
与 HashSet 不同的是,TreeSet 本身属于排序的子类,此类的定义如下:
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, Serializable
- 一个
NavigableSet
实现基于一个TreeMap
。 element使用其有序natural ordering ,或由Comparator
集合创建时提供,这取决于所使用的构造方法。此实现提供了基本的操作(保证的log(n)时间成本add
,remove
和contains
)。请注意,如果要正确实现
Set
接口,则由集合维护的排序(无论是否提供显式比较器)必须与equals一致 。 (有关与equals一致的精确定义,请参阅Comparable
或Comparator
)这是因为Set
接口是根据equals
操作定义的,但是TreeSet
实例使用其compareTo
(或compare
)方法执行所有元素比较,因此从该集合的角度来看,通过这种方法被认为相等的元素是相等的。 一套的行为是明确的,即使它的排序和equals不一致; 它只是没有遵守Set
接口的一般合同。
TreeSet不是线程安全的。如果多线程并发访问TreeSet,并且至少一个线程修改了set,必
须进行外部加锁。或者使用SortedSet s = Collections.synchronizedSortedSet(new TreeSet(...))
持有fail-fast机制。
LinkedHashSet
LinkedHashSet是Set接口的Hash表和LinkedList的实现。这个实现不同于HashSet的是它维护着
一个贯穿所有条目的双向链表。此链表定义了元素插入集合的顺序。如果元素重新插入,插入顺序不会受到影响。
LinkedHashSet有两个影响其构成的参数:初始容量和加载因子。它们的定义与HashSet完全相
同。但对于LinkedHashSet,选择过高的初始容量值的开销要比HashSet小,因为LinkedHashSet的迭代次数不受容量影响。LinkedHashSet也不是线程安全的,如果多线程同时访问LinkedHashSet,必须加锁,或者通过使用Collections.synchrontzedSet.该类也支持fail-fast机制
排序的说明(重点)
Set<Person> all = new TreeSet<Person>();
all.add(new Person("张三", 11));
all.add(new Person("李四", 10));
all.add(new Person("李四", 10));
all.add(new Person("王二", 10));
报如下异常:
表示Person 类不能向 Comparable 接口转型, 因此要进行排序的话,必须在 Person 类中实现 Comparable 接口。
class Person implements Comparable<Person>{
String name;
int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int compareTo(Person o) {//自定义比较的规则
if (this.age>o.age){
return 1;
}
else if (this.age>o.age){
return 0;
}
return -1;
}
}
在Person实现了Comparable接口后,输出[Person{name='李四', age=10},Person{name='张三', age=11}],从以上的结果中可以发现,一个李四和王二没有了。因为李四的年龄和王二的年龄是一样的,所以会被认为是同一个对象。则此时必须修改 Person 类,如果假设年龄相等的话,按字符串进行排序。(装实现了comparable接口的类的Collections可以被Collections.sort排序)
public int compareTo(Person o) {
if (this.age>o.age){
return 1;
}
else if (this.age==o.age){
return name.compareTo(o.name);
}
return -1;
}
[Person{name='李四', age=10}, Person{name='王二', age=10}, Person{name='张三', age=11}]
之前使用 Comparable 完成的对于重复元素的判断,那么 Set 接口定义的时候本身就是不允许重复元素的,不过如果现在真的是有重复元素的话,使用 HashSet 也同样可以进行区分。
Set<Person> all = new HashSet<Person>();
all.add(new Person("张三", 11));
all.add(new Person("李四", 10));
all.add(new Person("李四", 10));
all.add(new Person("王二", 10));
System.out.println(all);
打印结果为 [Person{name='张三', age=11}, Person{name='李四', age=10}, Person{name='李四', age=10}, Person{name='王二', age=10}]可得并没有去掉重复元素,也就是说之前的操作并不是真正的重复元素的判断,而是通过 Comparable 接口间接完成的。 如果要想判断两个对象是否相等,则需要判断两个对象的编码是否一致(通过 hashCode()完成),即:每个对象有唯一的编码 , 还需要进一步验证对象中的每个属性是否相等,需要通过 equals()完成。 所以此时需要重写 Object 类中的 hashCode()方法,此方法表示一个对象的唯一的编码,通过算法计算出来。
HashSet如果要想去掉重复元素需要依靠 hashCode()和 equals()方法共同完成(因为内部使用hashmap存储)。
总结:关于 TreeSet 的排序实现,如果是集合中对象是自定义的或者说其他系统定义的类没有实现 Comparable 接口,则不能实现 TreeSet 的排序,会报类型转换(转向 Comparable 接口)错误。 不过 TreeSet 的集合因为借用了 Comparable 接口,同时可以去除重复值,而 HashSet 虽然是 Set 接口子类,但是对于没有复写 Object 的 equals 和 hashCode 方法的对象,加入了 HashSet 集合中也是不能去掉重复值的。
Comparable 和 Comparator 的区别
Queue接口
Queue是和List、Set接口并列的Collection的三大接口之一。Queue的设计用来在处理之前保持元
素的访问次序。除了Collection基础的操作之外,队列提供了额外的插入,读取,检查操作。
集合输出
Iterator 迭代输出(90%)、ListIterator(5%)、Enumeration(1%)、foreach(4%) 但是在输出的时候一定要记住以下的原则:“只要是碰到了集合,则输出的时候想都不想就使用 Iterator 进行输出。”(可以使用for-Each语法糖)
Map 接口(重点)
Collection 中,每次操作的都是一个对象,如果现在假设要操作一对对象,则就必须使用 Map 了,那么保存以上信息的时候使用 Collection 就不那么方便,所以要使用 Map 接口。里面的所有内容都按照 key value 的形式保存,也称为二元偶对象。
public interface Map<K,V>
Map接口与 Collection 接口没有任何的关系,是第二大的集合操作接口。此接口常用方法如下:
一般会使用Map接口的几个子类:HashMap、TreeMap、Hashtable
HashMap(重点)
HashMap 是 Map 的子类,此类继承了 AbstractMap 类,同时可以被克隆,可以被序列化下来。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
HashMap是一个利用哈希表原理来存储元素的集合,并且允许空的key-value键值对。HashMap是非线程安全的,也就是说在多线程的环境下,可能会存在问题,而Hashtable是线程安全的容器。
HashMap也支持fail-fast机制。HashMap的实例有两个参数影响其性能:初始容量和加载因子。可
以使用Colltections.synchronizedMap(CnewHashMap(...))来构造一个线程安全的HashMap。
Map<Integer, String> map = new HashMap<Integer, String>();
map.put(1, "张三A");
map.put(1, "张三B"); // 新的内容替换掉旧的内容(返回张三A)
map.put(2, "李四");//返回null
String val = map.get(6);/null
System.out.println(val);
map.remove(1)//返回张三B
map.remove(3)//返回null
Set<Integer> set = map.keySet(); // 得到全部的key
Collection<String> value = map.values(); // 得到全部的value(极少使用)
Iterator<Integer> iter1 = set.iterator();
Iterator<String> iter2 = value.iterator();
Map没有直接遍历键值对的方法,只能通过 map.KeySet()得到全部的Key,然后使用get方法得到对应该键的值
Set<String> set = map.keySet(); // 得到全部的key
Iterator<String> iter = set.iterator();
while (iter.hasNext()) {
String i = iter.next(); // 得到key
System.out.println(i + " --:> " + map.get(i));
}
或者
for(String s:set){
System.out.println(i + " --:> " + map.get(s));
}
HashTable
Hashtable 是一个最早的 key value 的操作类,本身是在 JDK 1.0 的时候推出的。其基本操作与 HashMap 是类似的。操作的时候,可以发现与 HashMap 基本上没有什么区别,而且本身都是以 Map 为操作标准的,所以操作的结果形式 都一样。但是 Hashtable 中是不能向集合中插入 null 值的。
HashMap 与 Hashtable 的区别
在整个类集中除了 ArrayList 和 Vector 的区别之外,另外一个最重要的区别就是 HashMap 与 Hashtable 的区别
TreeMap
一个基于NavigableMap实现的红黑树。这个map根据key自然排序存储,或者通过Comparator进
行定制排序。TreeMap为containsKey,get,put和remove方法提供了log(n)的时间开销。
TreeMap不是线程安全的。如果多线程并发访问TreeMap,并且至少一个线程修改了map,
必须进行外部加锁。这通常通过在自然封装集合的某个对象上进行同步来实现,或者使用SortedMap m = Collections.synchronizedSortedMap(new TreeMap(...)) 这个实现持有fail-fast机制。
HashMap 线程不安全,效率高
HashTable 线程安全,效率低
ConcurrentHashMap 采用分段锁机制,保证线程安全,效率又比较高
TreeMap 不保证存储顺序,但是会进行排序
LinkedHashMap
LinkedHashMap是Map接口的哈希表和链表的实现。这个实现与HashMap不同之处在于它维护了一个贯穿其所有条目的双向链表。这个链表定义了遍历顺序,通常是插入map中的顺序。它提供一个特殊的LinkedHashMap(int,float,boolean)构造器来创建LinkedHashMap,其遍历顺序是其最后一次访问的顺序。可以重写removeEldestEntry(Map.Entry)方法,以便在将新映射添加到map时强制删除过期映射的策略。这个类提供了所有可选择的map操作,并且允许null元素。由于维护链表的额外开销,性能可能会低于HashMap,有一条除外:遍历LinkedHashMap中的collection-views需要与map.size成正比,无论其容量如何。HashMap的迭代看起来开销更大,因为还要求时间与其容量成正比。LinkedHashMap有两个因素影响了它的构成:初始容量和加载因子。
LinkedHashMap不是线程安全的。如果多线程并发访问LinkedHashMap,并且至少一个线程修改了
map,必须进行外部加锁。或Map m = Collections.synchronizedMap(new LinkedHashMap(...))
这个实现持有fail-fast机制。
Map集合的输出
在 Collection 接口中,可以使用 iterator()方法为 Iterator 接口实例化,并进行输出操作,但是在 Map 接口中并没有此方法的定义,所以 Map 接口本身是不能直接使用 Iterator 进行输出的。 因为 Map 接口中存放的每一个内容都是一对值,而使用 Iterator 接口输出的时候,每次取出的都实际上是一个完整的对象。如果此时非要使用 Iterator 进行输出的话,则可以按照如下的步骤进行: 1、 使用 Map 接口中的 entrySet()方法将 Map 接口的全部内容变为 Set 集合 2、 可以使用 Set 接口中定义的 iterator()方法为 Iterator 接口进行实例化 3、 之后使用 Iterator 接口进行迭代输出,每一次的迭代都可以取得一个 Map.Entry 的实例 4、通过 Map.Entry 进行 key 和 value 的分离 那么,到底什么是 Map.Entry 呢? Map.Entry 本身是一个接口。此接口是定义在 Map 接口内部的,是 Map 的内部接口。此内部接口使用 static 进行定义,所以此接口将成为外部接口。 实际上来讲,对于每一个存放到 Map 集合中的 key 和 value 都是将其变为了 Map.Entry 并且将 Map.Entry 保存在了 Map 集合之中。
Set<Map.Entry<String, String>> set = map.entrySet();// 变为Set实例
Iterator<Map.Entry<String, String>> iter = set.iterator();
while (iter.hasNext()) {
Map.Entry<String, String> me = iter.next();
System.out.println(me.getKey() + " --> " + me.getValue());
}
//或者使用for-each语法糖
for (Map.Entry<String, String> me : map.entrySet()) {
System.out.println(me.getKey() + " --> " + me.getValue());
}
JDK9新特性创建固定长度的数组(一旦创建无法改变)
只有List,Set,Map三个接口有这些重载的of静态方法
List<String> haha1 = List.of("haha1", "haha2");
List<String> haha2 = List.of("haha1", "haha2","haha3");
haha1.add("haha3");//运行时报UnsupportedOperationException异常
for (String s : haha2) {
System.out.println(s);
}
Map<Integer, String> integerStringMap = Map.of(1, "sky", 2, "sun");
Set<Integer> integers = integerStringMap.keySet();
for (Integer integer : integers) {
System.out.println(integerStringMap.get(integer));
}
equals、hashCode 与内存泄露的初步分析
java.lang.Object 中对 hashCode 的约定(重要):
- 在一个应用程序执行期间,如果一个对象的 equals 方法做比较所用到的信息没有被修改的话,则对该对象调用 hashCode 方法多次,它必须始终如一地返回同一个整数。
- 如果两个对象根据 equals(Object o)方法是相等的,则调用这两个对象中任一对象的 hashCode 方法必须产生相同的整数结果。
- 如果两个对象根据 equals(Object o)方法是不相等的,则调用这两个对象中任一个对象的 hashCode 方法,不要求产生 不同的整数结果。但如果能不同,则可能提高散列表的性能。
在 java 的集合中,判断两个对象是否相等的规则是:
判断两个对象的 hashCode 是否相等 如果不相等,认为两个对象也不相等,如果相等,则判断两个对象用 equals 运算是否相等 如果不相等,认为两个对象也不相等 ,如果相等,认为两个对象相等(equals()是判断两个对象是否相等的关键)
注意: 当一个对象被存进 HashSet 集合后,就不能修改这个对象中的那些参与计算的哈希值的字段了,否则,对象被修改后的哈 希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在 contains 方法使用该对象的当前引用作为 的参数去 HashSet 集合中检索对象,也将返回找不到对象的结果,这也会导致无法从 HashSet 集合中删除当前对象,从而 造成内存泄露。
总结
- 类集就是一个动态的对象数组,可以向集合中加入任意多的内容。
- List 接口中是允许有重复元素的,Set 接口中是不允许有重复元素。
- 所有的重复元素依靠 hashCode()和 equals 进行区分
- TreeSet 是可以排序,一个类的对象依靠 Comparable 接口排序
- Map 使用 Iterator 输出的详细步骤