Java常用集合类
根据数据存储格式的不同,Java集合可以分为两类,一类是以Collection为基类的线性表类(list和set子接口),另一类是以Map为基类的键值对类,集合是容器,其中可以存储基本数据类型和自定义Class类型数据。
List接口
List集合是线性数据结构的主要实现,在集合中保存的都是引用类型(如果是基本数据类型的话,会自动转成包装类),List是有序的,可重复的,有索引的集合,List接口的实现类有ArrayList、Linked List、Vector,下面我们来具体分析一下这些实现类。
ArrayList
ArrayList继承于AbstractList抽象类,是容量可以改变的非线程安全集合,内部实现使用数组进行存储,在扩容时会创建更大的数组空间,把原有的数据复制到新数组中,初始化默认长度为10,超出后以0.5倍延长,性能消耗主要体现在数据的移动和复制上。
ArrayList特点为查询快、增删慢、线程不够安全、效率高。
LinkedList
LinkedList本质是双向链表,除了继承AbstractList外还实现了Deque接口,同时具有队列和栈的性质(队列:先进先出;栈:后进先出),其性能消耗在于如果想查找某一元素的话,只能从头依次查找,但是添加或删除元素的时候,只需要改变指针即可,不需要移动元素。
LinkedList的特点为查询慢、增删快、线程不安全、效率高且内存利用率高(通过链表可以将零散的内存单元关联起来,形成按链路顺序查找的线性结构)。
Vector
Vector底层同样是数组结构,默认长度为10,超出后以1倍延长,可能会浪费空间。
Vector的特点是查询快、增删慢、线程安全、效率不够高,几乎已经不用。
Set接口
set是不允许出现重复元素的集合类型,其实现类有HashSet、LinkedHashset、TreeSet。
Hash Set
Hash Set继承于AbstractSet抽象类,从源码分析是用HashMap实现的,将value固定为一个静态对象,使用key保证集合元素的唯一性,但不保证元素的添加顺序,可以添加null元素。
HashSet存储过程分析
当向HashSet添加元素时,会先调用该对象的hashCode()方法,得到该对象的hashCode值,然后根据hashCode值决定该对象在HashSet中的存储位置。
如果hashCode不同,直接把元素存储到hashCode指定的位置上;
如果hashCode相同,则继续通过equals方法判断该元素和集合中的对象是否为同一对象,如果equals为true,则视为同一个对象,不保存;如果equals为false,则将元素存储在之前对象同槽位的链表上,这非常麻烦,应该避免这种情况的发生,即应该保证equals为true时,这两个对象的hashCode值也应该相同,所以对象所在类必须重写hashCode和equals方法(基本数据类型和String类型已经重写过了hashCode和equals方法)。
LinkedHashSet
Linked Hash Set继承自HashSet,具有HashSet的优点,内部使用链表维护了元素的插入顺序,存取一致,即通过链表保证元素添加的顺序,通过哈希保证元素的唯一性。
TreeSet
TreeSet从源码分析是用TreeMap实现的,底层为树结构,在添加元素时会自动排序但不保证元素添加的顺序,要求放入TreeSet中对象所在类必须实现Comparable接口,重写CompareTo和equals方法,保证元素插入集合中仍然有序,且元素类型必须一致,不然会报错。
Map接口
Map集合是以key-value键值对作为存储元素实现的哈希结构,存储无序,可以使用keySet()获取所有的key,使用values()获取所有的value,Map.entrySet()查看所有的key-value。
Map的实现类有HashMap、Linked Hash Map、Tree Map、HashTable、ConcurrentHashMap等。
HashMap
最常用的map,可以存放null值,线程不安全,访问速度快。
LinkedHashMap
HashMap的子类,可以存放null值,保证记录的插入顺序,线程不安全。
TreeMap
保存时根据key排序,不可以存放null值,线程不安全。
HashTable
不可以存放null值,线程安全,性能不高,几乎被淘汰
ConcurrenHashMap
不可以存放null值,在多线程并发场景中,优先推荐使用。
集合中存放的是引用:通过浅复制和深复制理解
我们自定义的类是以引用的形式放入集合中的,如使用不当会引发隐蔽的错误,举个栗子,
定义个类,其中有个id属性,然后创建一个实例,并初始化id为1,然后将这个实例分别放入两个list中,然后在其中一个list中更改这个实例的id为2,另一个不做修改,那么另一个没做修改中的实例的id属性是1还是2?通过代码演示一下,
public class StringTest {
public static void main(String[] args) {
TestClass tc=new TestClass();
tc.id=1;
ArrayList <TestClass>list1=new ArrayList<TestClass>();
ArrayList <TestClass>list2=new ArrayList<TestClass>();
list1.add(tc);
list2.add(tc);
list1.get(0).setId(2);
System.out.println(list2.get(0).getId());
}
}
class TestClass{
int id;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
}
打印结果为2,其原因就是集合中存放的是引用,结合图来说明一下,
如图所示,我们对list1中的tc做更改时,其实是通过tc引用改变了内存中的id,由于list2存放的也是tc引用,虽然没有更改list2中的存放的tc对象,但list2中的值也跟着变了。
通常我们将同一个对象放到两个不同的集合中时,本意是想为该对象做一个备份,但是上面的做法与我们的预期结果不一样,如果要正确的实现上面描述的备份效果,就需要通过clone方法来实现深复制,代码如下,
public class StringTest {
public static void main(String[] args) {
TestClass tc=new TestClass();
tc.id=1;
TestClass tc2=null;
try {
tc2=(TestClass) tc.clone();
} catch (CloneNotSupportedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
ArrayList <TestClass>list1=new ArrayList<TestClass>();
ArrayList <TestClass>list2=new ArrayList<TestClass>();
list1.add(tc);
list2.add(tc2);
list1.get(0).setId(2);
System.out.println(list2.get(0).getId());
}
}
class TestClass implements Cloneable{
int id;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
@Override
protected Object clone() throws CloneNotSupportedException {
// TODO Auto-generated method stub
return super.clone();
}
}
输出结果仍为1。
为了实现clone,我们必须将自定义的类实现Cloneable接口,并重写其中的clone方法,来完成对象的复制,当tc被clone后,系统会开辟一块新的空间存放和tc相同的内容,然后赋值给tc2,这时再用两个list分别存放tc和tc2,就是存放了两个不同的引用,所以更改list1中的tc就不会影响list2中tc2.
各集合使用场景及优化
1、首先根据需求确定集合的类型,如果是单列的考虑使用Collection下的子接口List和Set,如果是映射关系就用Map。
2、确定好集合类型后,再确定使用该集合下的哪个子类,如果迭代时需要有序,就找Linked双向列表结构的集合;如果需要对元素排序就找Tree类型的集合。
3、预计估算集合的数据量,无论是List还是Map,它们实现动态增长都有一定的性能消耗,可以在初始化集合时,给出一个合理的容量,这样会减少动态增长的消耗。
4、使用泛型,可以避免出现异常。
5、尽可能使用集合工具类,它有更好的稳定性和维护性。
异常处理
在程序中,错误(Error)和异常(Exception)会影响正常的运行流程,在java中分别有Error类和Exception类,它们都是Throwable的子类。
对于Error来说,仅靠程序代码本身无法有效地恢复,所以我们一般不做任何处理直接终止程序,不需要过多的关注它的语法。
而Exception是在程序中需要关心的异常类,它会派生一些子类分别处理不同情况抛出的异常。
try…catch…finally语句
try代码块:监听代码执行过程,一旦发现异常直接跳转至catch,如果没有catch则直接跳到 finally。
catch代码块:如果没有异常发生则不会执行;如果发生异常则进行处理或向上抛出。
finally代码块:必选执行代码块,不管是否有异常发生,都会执行。
finally代码块没有被执行的几种可能:
1、没有进入try代码块
2、进入try代码块,但是执行了System.exit()
3、进入try代码块,但是代码运行中出现了死循环或死锁状态
finally执行的特点:
1、finally是在return表达式运行后执行的,此时将return的结果暂存起来,待finally执行结束后,再将之前暂存的结果返回,如,
int temp=1000;
try {
return ++temp;
}finally {
temp=999;
}
最终返回temp=1001;
2、在finally中使用return,很危险,应避免,比如,
public class StringTest {
static int x=1;
public static void main(String[] args) {
System.out.println(test());
}
static int test() {
try {
return ++x;
}finally{
return ++x;
}
}
}
最终返x=3,因为最终的返回动作有finally完成的。
3、锁应加在try代码块之外,避免对未加锁对象解锁,如,
try {
lock.lock();
}finally {
lock.unlock();
}
这样写存在隐患,因为进入try代码块中,无论加锁是否成功,都会执行finally的解锁方法,造成了对未加锁对象解锁。
throw ,throws的区别
通过一段代码说明,throw和throws的用法,
public class StringTest {
public void test()throws Exception{
throw new Exception();
}
public static void main(String[] args) {
StringTest stringTest=new StringTest();
try {
stringTest.test();
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
因为test方法内部通过throw抛出了异常,所以在声明该函数时,一定要配套使用throws,不然会报错,如果throw抛出的是RuntimeException,则不需要使用throws声明。
由于main方法中调用了抛出异常的方法,所以在调用test方法时要用try catch包起来,不然也会报错。
下面总结一下throw和throws使用要点:
1、throw在函数体中使用,而throws出现在声明方法的位置。
2、如果方法内部通过throw抛出了异常,则在声明该方法时,一定要配套使用throws,否则会报错,RuntimeException除外。
3、如果调用一个抛出异常的方法,则在调用该方法的地方要用try catch包起来。
异常处理部分使用要点
1、尽量使用try catch finally处理异常,在finally中应当尽可能的回收内存资源。
2、尽量减少用try监控的代码块,无需被监控的代码,不应放到try 中。
3、先用专业的异常来处理,最后在用Exception异常来处理,如,
try {
io代码
数据库连接代码
}
catch(IOException ioe) {
处理io异常的代码
}
catch(SQLException ioe) {
处理数据库操作异常的代码
}
catch (Exception e) {
最后用Exception异常
}
4、出现异常后,应尽量保证项目不会终止,把异常的影响降到最低。
例如,有两个平行的业务,即使其中一个业务出现异常,另一个业务也应该正常执行,错误的写法,
try {
业务1
业务2
}
catch (Exception e) {
处理异常
}
应当写成
try {
业务1
}
catch (Exception e) {
处理异常
}
try {
业务2
}
catch (Exception e) {
处理异常
}
再有,例如从文件中读100条数据,然后依次写入数据库,即使其中一条插入出错,也不能影响其他插入动作,
先看错误的写法,
try {
for(int i=0;i<=100;i++) {
读其中的一条数据,并插入数据库
}
}
catch (Exception e) {
处理异常
}
这样写的话,如果第一条就出现异常,那么就直接跳到catch中,这样其他数据也无法插入了,那么我们再看正确的写法,
for(int i=0;i<=100;i++) {
try {
读其中的一条数据,并插入数据库
}
catch (Exception e) {
处理异常;
continue;
}
}
这样的话,即使某条插入失败,也不会影响后续的插入动作,从而把影响降到了最低。