containsKey and get have the same performance characteristics. The majority of the CPU time used will be within the hashCode and equals methods for the objects used as keys. Poorly designed keys will have poor performance characteristics, particularly when you're Using Custom Types in Map Keys and Sets.
In practice, I've found that the majority of the time, you'll need to use the results that you get back from the map, so it is important that you cache the results of get so that you're not using containsKey or get more frequently than necessary. In other words, the optimal design choice usually look like this:
// Replace "Object" with the appropriate data type
Object value = someMap.get(key);
if(value != null) {
// Do something with value
}
This pattern means you won't need to call both containsKey and get, which reduces CPU time, sometimes significantly. As I stated above, make sure that, if you're using custom types for keys, that non-equal values return unique hashCode values.
For example, this is a bad key design:
public class Key {
public Integer hashCode() {
return 1;
}
public Boolean equals(Object o) {
return o === this;
}
}
This is because the number of equals methods that will be called is up to the number of elements in the set or map key set, which can easily be hundreds of times more costly than an optimal key design:
public class Key {
static Integer counter = 0;
Integer code;
public Key() {
code = counter++;
}
public Integer hashCode() {
return code;
}
public Boolean equals(Object o) {
return o === this;
}
}
By returning unique hashCode values for values which are not equal, you'll drastically improve the performance of both get and containsKey.
Keep in mind that most of the time, you don't care if a map contains a particular key, you care about the value being returned having a null value (to avoid NullPointerException). It is incredibly rare that you'll ever only care about the presence of a key in a map (containsKey), and it is extremely common that you'll care about NullPointerException.
So, in conclusion, I would say that the most efficient design pattern will almost always be to cache the results of get, and not use containsKey. containsKey is almost always just a CPU sink, wasting precious CPU cycles when you're just going to be calling get anyways. It takes approximately the same amount of time to call get and containsKey, and it's virtually guaranteed that after you call containsKey, you're going to call get anyways, so you may as well cut out the middle man.
If you write code that ever only uses containsKey and doesn't use get, you should be using a Set, not a Map. If you're using containsKey and get, you're probably using a sub-optimal algorithm. You should call get no more than once per key for optimal performance.
The rare case for using containsKey used to be when initializing lists or sets in maps, like this:
Map<Id, Contact[]> contacts = new Map<Id, Contact[]>();
for(Contact record: [SELECT AccountId FROM Contact]) {
if(contacts.containsKey(record.AccountId)) {
contacts.get(record.AccountId).add(record);
} else {
contacts.put(record.AccountId, new List<Contact> { record });
}
}
However, in the past year or so, I've designed an even more optimal design:
Contact[] temp;
Map<Id, Contact[]> contacts = new map<Id, Contact[]>();
for(Contact record: [SELECT AccountId FROM Contact]) {
if((temp = contacts.get(record.AccountId)) == null) {
contacts.put(record.AccountId, temp = new Contact[0]);
}
temp.add(record);
}
While you'll want to add comments to explain this, this solution combines a null check with caching to produce optimal CPU usage. When you want to aggregate data together this way, this combined caching strategy outperforms any other design I've written seen or used by a large margin, particularly when you need the performance boost in triggers.